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.
Files changed (52) hide show
  1. simconnect_mcp/__init__.py +10 -0
  2. simconnect_mcp/__main__.py +5 -0
  3. simconnect_mcp/connection.py +620 -0
  4. simconnect_mcp/data/__init__.py +1 -0
  5. simconnect_mcp/data/catalog.py +240 -0
  6. simconnect_mcp/data/hubhop.py +647 -0
  7. simconnect_mcp/data/pmdg_737.json +22835 -0
  8. simconnect_mcp/data/pmdg_777.json +20669 -0
  9. simconnect_mcp/data/simvar_catalog.py +162 -0
  10. simconnect_mcp/data/simvars_catalog.json +6538 -0
  11. simconnect_mcp/dispatch.py +427 -0
  12. simconnect_mcp/docs/best_practices.md +118 -0
  13. simconnect_mcp/docs/events.md +119 -0
  14. simconnect_mcp/docs/lvars.md +102 -0
  15. simconnect_mcp/docs/overview.md +65 -0
  16. simconnect_mcp/docs/pmdg_737.md +89 -0
  17. simconnect_mcp/docs/pmdg_777.md +492 -0
  18. simconnect_mcp/docs/rpn.md +150 -0
  19. simconnect_mcp/docs/simvars.md +124 -0
  20. simconnect_mcp/facilities.py +305 -0
  21. simconnect_mcp/pmdg.py +908 -0
  22. simconnect_mcp/pmdg_detect.py +194 -0
  23. simconnect_mcp/pmdg_ng3.py +907 -0
  24. simconnect_mcp/prompts/__init__.py +1 -0
  25. simconnect_mcp/prompts/templates.py +191 -0
  26. simconnect_mcp/resources/__init__.py +1 -0
  27. simconnect_mcp/resources/documentation.py +119 -0
  28. simconnect_mcp/resources/state.py +57 -0
  29. simconnect_mcp/server.py +264 -0
  30. simconnect_mcp/simvar_access.py +622 -0
  31. simconnect_mcp/tools/__init__.py +130 -0
  32. simconnect_mcp/tools/aircraft.py +164 -0
  33. simconnect_mcp/tools/connection_tools.py +59 -0
  34. simconnect_mcp/tools/events.py +541 -0
  35. simconnect_mcp/tools/facilities.py +444 -0
  36. simconnect_mcp/tools/flight.py +602 -0
  37. simconnect_mcp/tools/formatting.py +118 -0
  38. simconnect_mcp/tools/hubhop.py +294 -0
  39. simconnect_mcp/tools/lvars.py +897 -0
  40. simconnect_mcp/tools/models.py +482 -0
  41. simconnect_mcp/tools/pmdg.py +533 -0
  42. simconnect_mcp/tools/simvars.py +455 -0
  43. simconnect_mcp/tools/utilities.py +294 -0
  44. simconnect_mcp/vendor/MOBIFLIGHT_LICENSE +21 -0
  45. simconnect_mcp/vendor/__init__.py +6 -0
  46. simconnect_mcp/vendor/mobiflight_variable_requests.py +204 -0
  47. simconnect_mcp/vendor/simconnect_mobiflight.py +39 -0
  48. simconnect_mcp-1.0.0.dist-info/METADATA +464 -0
  49. simconnect_mcp-1.0.0.dist-info/RECORD +52 -0
  50. simconnect_mcp-1.0.0.dist-info/WHEEL +4 -0
  51. simconnect_mcp-1.0.0.dist-info/entry_points.txt +2 -0
  52. 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,5 @@
1
+ """Entry point for `python -m simconnect_mcp`."""
2
+
3
+ from simconnect_mcp.server import main
4
+
5
+ main()
@@ -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."""