fsd9lib 0.1.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.
fsd9/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ FSD9 — A Python library for FSD (Flight Simulator Data) protocol v9.
3
+
4
+ Provides a complete implementation of the classic FSD protocol for
5
+ VATSIM-compatible pilot client connections. Supports protocol revision 9
6
+ (legacy plaintext authentication).
7
+
8
+ Quick start::
9
+
10
+ import asyncio
11
+ from fsd9 import PilotClient
12
+
13
+ async def main():
14
+ client = PilotClient(
15
+ callsign="N7938C",
16
+ cid="123456",
17
+ password="secret",
18
+ server="eu.velocity.vatsim.net",
19
+ )
20
+
21
+ async def on_msg(client, sender, text):
22
+ print(f"[{sender}] {text}")
23
+
24
+ client.on_message = on_msg
25
+
26
+ await client.connect()
27
+ await client.send_position(40.659, -73.798, 35000, 450, 2.0, 0.0, 90.0)
28
+ # ... do stuff ...
29
+ await client.disconnect()
30
+
31
+ asyncio.run(main())
32
+ """
33
+
34
+ from .client import PilotClient
35
+ from .connection import FSDConnection
36
+ from .constants import (
37
+ Capability,
38
+ FacilityType,
39
+ FlightRules,
40
+ NetworkRating,
41
+ PilotRating,
42
+ Recipient,
43
+ ServerError,
44
+ SimulatorType,
45
+ TransponderMode,
46
+ FSD_DEFAULT_PORT,
47
+ PROTOCOL_REVISION_CLASSIC,
48
+ CALLSIGN_MIN_LENGTH,
49
+ CALLSIGN_MAX_LENGTH,
50
+ KNOWN_CLIENT_IDS,
51
+ ATC_VISUAL_RANGES,
52
+ )
53
+ from .errors import (
54
+ FSDError,
55
+ FSDConnectionError,
56
+ FSDLoginError,
57
+ FSDProtocolError,
58
+ FSDServerError,
59
+ FSDCallsignError,
60
+ FSDSquawkError,
61
+ FSDFrequencyError,
62
+ )
63
+ from . import frequency
64
+ from . import pbh
65
+ from . import packet
66
+
67
+ __version__ = "0.1.0"
68
+ __all__ = [
69
+ # Client
70
+ "PilotClient",
71
+ "FSDConnection",
72
+ # Enums
73
+ "NetworkRating",
74
+ "PilotRating",
75
+ "SimulatorType",
76
+ "FacilityType",
77
+ "TransponderMode",
78
+ "FlightRules",
79
+ "ServerError",
80
+ "Capability",
81
+ "Recipient",
82
+ # Constants
83
+ "FSD_DEFAULT_PORT",
84
+ "PROTOCOL_REVISION_CLASSIC",
85
+ "CALLSIGN_MIN_LENGTH",
86
+ "CALLSIGN_MAX_LENGTH",
87
+ "KNOWN_CLIENT_IDS",
88
+ "ATC_VISUAL_RANGES",
89
+ # Errors
90
+ "FSDError",
91
+ "FSDConnectionError",
92
+ "FSDLoginError",
93
+ "FSDProtocolError",
94
+ "FSDServerError",
95
+ "FSDCallsignError",
96
+ "FSDSquawkError",
97
+ "FSDFrequencyError",
98
+ # Modules
99
+ "frequency",
100
+ "pbh",
101
+ "packet",
102
+ ]
fsd9/client.py ADDED
@@ -0,0 +1,558 @@
1
+ """
2
+ High-level FSD Protocol v9 Pilot Client.
3
+
4
+ Provides a simple async API for connecting to an FSD server as a pilot
5
+ (flight crew), sending position updates, filing flight plans, and
6
+ receiving messages.
7
+
8
+ Usage::
9
+
10
+ import asyncio
11
+ from fsd9 import PilotClient, NetworkRating, SimulatorType
12
+
13
+ async def main():
14
+ client = PilotClient(
15
+ callsign="N7938C",
16
+ cid="123456",
17
+ password="secret",
18
+ server="eu.velocity.vatsim.net",
19
+ )
20
+ client.on_message = lambda msg: print(f"[MSG] {msg}")
21
+
22
+ await client.connect()
23
+
24
+ # Send position every 5 seconds
25
+ await client.send_position(40.65906, -73.79891, 35000, 450, 2.0, 0.0, 90.0)
26
+
27
+ await client.disconnect()
28
+
29
+ asyncio.run(main())
30
+ """
31
+
32
+ import asyncio
33
+ import logging
34
+ import math
35
+ import time
36
+ from typing import Optional, Callable, Awaitable
37
+
38
+ from .connection import FSDConnection
39
+ from .constants import (
40
+ FSD_DEFAULT_PORT,
41
+ NetworkRating,
42
+ PilotRating,
43
+ SimulatorType,
44
+ TransponderMode,
45
+ FlightRules,
46
+ Recipient,
47
+ ServerError,
48
+ )
49
+ from .errors import FSDError, FSDLoginError, FSDServerError
50
+ from . import packet
51
+ from . import frequency as _freq
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # Callback type aliases
56
+ MessageCallback = Callable[["PilotClient", str, str], Awaitable[None]]
57
+ """Callback for text messages: (client, sender, message)"""
58
+
59
+ PositionCallback = Callable[["PilotClient", str, dict], Awaitable[None]]
60
+ """Callback for position updates: (client, callsign, parsed_position)"""
61
+
62
+ ErrorCallback = Callable[["PilotClient", FSDServerError], Awaitable[None]]
63
+ """Callback for server errors: (client, error)"""
64
+
65
+
66
+ class PilotClient:
67
+ """Async FSD protocol v9 pilot client.
68
+
69
+ Handles login, position updates, flight plan filing, text messaging,
70
+ and all incoming message parsing.
71
+
72
+ Attributes:
73
+ callsign: The pilot's callsign
74
+ cid: VATSIM Certificate ID
75
+ connected: Whether currently connected and logged in
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ callsign: str,
81
+ cid: str,
82
+ password: str,
83
+ server: str = "eu.velocity.vatsim.net",
84
+ port: int = FSD_DEFAULT_PORT,
85
+ *,
86
+ real_name: str = "",
87
+ sim_type: SimulatorType = SimulatorType.MSFS2020,
88
+ network_rating: NetworkRating = NetworkRating.OBS,
89
+ protocol_revision: int = 9,
90
+ client_name: str = "fsd9lib",
91
+ # Position state
92
+ auto_position: bool = False,
93
+ position_interval: float = 5.0,
94
+ ):
95
+ """Initialize the pilot client.
96
+
97
+ Args:
98
+ callsign: Pilot callsign (3–11 characters)
99
+ cid: VATSIM Certificate ID
100
+ password: Plaintext password (protocol 9)
101
+ server: FSD server hostname
102
+ port: FSD server port (default 6809)
103
+ real_name: Pilot's real name
104
+ sim_type: Simulator type enum
105
+ network_rating: Pilot network rating
106
+ protocol_revision: Protocol revision (9 for classic)
107
+ client_name: Human-readable client name for logging
108
+ auto_position: If True, automatically send position at interval
109
+ position_interval: Position update interval in seconds (default 5s)
110
+ """
111
+ self.callsign = callsign
112
+ self.cid = cid
113
+ self.password = password
114
+ self.real_name = real_name
115
+ self.sim_type = sim_type
116
+ self.network_rating = network_rating
117
+ self.protocol_revision = protocol_revision
118
+ self.client_name = client_name
119
+
120
+ self._conn = FSDConnection(server, port, reconnect=False)
121
+ self._server = server
122
+ self._port = port
123
+
124
+ # Connection state
125
+ self._connected = False
126
+ self._logged_in = False
127
+ self._running = False
128
+ self._read_task: Optional[asyncio.Task] = None
129
+ self._position_task: Optional[asyncio.Task] = None
130
+
131
+ # Position auto-send
132
+ self.auto_position = auto_position
133
+ self.position_interval = position_interval
134
+
135
+ # Current position state (for auto-send)
136
+ self._current_position: Optional[dict] = None
137
+
138
+ # Callbacks
139
+ self.on_message: Optional[MessageCallback] = None
140
+ self.on_position: Optional[PositionCallback] = None
141
+ self.on_atc_position: Optional[PositionCallback] = None
142
+ self.on_error: Optional[ErrorCallback] = None
143
+ self.on_disconnect: Optional[Callable[["PilotClient"], Awaitable[None]]] = None
144
+
145
+ # Known clients (callsign → last parsed position)
146
+ self.known_clients: dict[str, dict] = {}
147
+ self.known_atc: dict[str, dict] = {}
148
+
149
+ # ── properties ───────────────────────────────────────────────────────
150
+
151
+ @property
152
+ def connected(self) -> bool:
153
+ return self._connected and self._logged_in
154
+
155
+ @property
156
+ def server(self) -> str:
157
+ return f"{self._server}:{self._port}"
158
+
159
+ # ── connection lifecycle ─────────────────────────────────────────────
160
+
161
+ async def connect(self) -> None:
162
+ """Connect to the FSD server and log in as a pilot.
163
+
164
+ Protocol 9 flow:
165
+ 1. Open TCP connection
166
+ 2. Send #AP with plaintext password
167
+ 3. Server validates and broadcasts login
168
+
169
+ Raises:
170
+ FSDLoginError: If login fails
171
+ FSDError: On protocol errors
172
+ """
173
+ await self._conn.connect()
174
+
175
+ # Send login (protocol 9: simple plaintext login)
176
+ login_pkt = packet.build_add_pilot(
177
+ callsign=self.callsign,
178
+ cid=self.cid,
179
+ password=self.password,
180
+ network_rating=self.network_rating,
181
+ protocol_revision=self.protocol_revision,
182
+ sim_type=self.sim_type,
183
+ real_name=self.real_name,
184
+ )
185
+ await self._conn.send_line(login_pkt)
186
+ logger.info(f"Sent login for {self.callsign}")
187
+
188
+ self._connected = True
189
+ self._logged_in = True
190
+ self._running = True
191
+
192
+ # Start message reader
193
+ self._read_task = asyncio.create_task(self._read_loop())
194
+
195
+ # Start auto-position if enabled
196
+ if self.auto_position:
197
+ self._position_task = asyncio.create_task(self._auto_position_loop())
198
+
199
+ logger.info(f"Logged in as {self.callsign} on {self.server}")
200
+
201
+ async def disconnect(self) -> None:
202
+ """Disconnect from the server gracefully."""
203
+ self._running = False
204
+
205
+ # Send logout
206
+ if self._logged_in:
207
+ try:
208
+ logout = packet.build_delete_pilot(self.callsign, self.cid)
209
+ await self._conn.send_line(logout)
210
+ except Exception:
211
+ pass
212
+
213
+ # Cancel tasks
214
+ for task in (self._read_task, self._position_task):
215
+ if task and not task.done():
216
+ task.cancel()
217
+ try:
218
+ await task
219
+ except asyncio.CancelledError:
220
+ pass
221
+
222
+ self._read_task = None
223
+ self._position_task = None
224
+
225
+ await self._conn.disconnect()
226
+ self._connected = False
227
+ self._logged_in = False
228
+
229
+ if self.on_disconnect:
230
+ try:
231
+ await self.on_disconnect(self)
232
+ except Exception as e:
233
+ logger.warning(f"on_disconnect callback failed: {e}")
234
+
235
+ # ── message reading ──────────────────────────────────────────────────
236
+
237
+ async def _read_loop(self) -> None:
238
+ """Main loop: read and dispatch incoming messages."""
239
+ while self._running:
240
+ try:
241
+ line = await self._conn.read_line()
242
+ except Exception as e:
243
+ logger.error(f"Read error: {e}")
244
+ break
245
+
246
+ if line is None:
247
+ break
248
+
249
+ try:
250
+ await self._dispatch(line)
251
+ except Exception as e:
252
+ logger.debug(f"Dispatch error for line {line!r}: {e}")
253
+
254
+ # Connection lost
255
+ self._connected = False
256
+ self._logged_in = False
257
+ if self.on_disconnect:
258
+ try:
259
+ await self.on_disconnect(self)
260
+ except Exception:
261
+ pass
262
+
263
+ async def _dispatch(self, line: str) -> None:
264
+ """Parse and dispatch a single incoming line."""
265
+ fields = line.rstrip("\r\n").split(":")
266
+
267
+ if not fields or not fields[0]:
268
+ return
269
+
270
+ prefix = packet.get_pdu_prefix(line)
271
+
272
+ # ── Pilot position ──
273
+ if prefix == "@":
274
+ data = packet.parse_pilot_position(fields)
275
+ cs = data["callsign"]
276
+ self.known_clients[cs] = data
277
+ if self.on_position and cs != self.callsign:
278
+ await self.on_position(self, cs, data)
279
+
280
+ # ── ATC position ──
281
+ elif prefix == "%":
282
+ data = packet.parse_atc_position(fields)
283
+ cs = data["callsign"]
284
+ self.known_atc[cs] = data
285
+ if self.on_atc_position:
286
+ await self.on_atc_position(self, cs, data)
287
+
288
+ # ── Fast positions ──
289
+ elif prefix in ("^", "#SL", "#ST"):
290
+ data = packet.parse_fast_position(fields)
291
+ cs = data["callsign"]
292
+ self.known_clients[cs] = data
293
+ if self.on_position and cs != self.callsign:
294
+ await self.on_position(self, cs, data)
295
+
296
+ # ── Text message ──
297
+ elif prefix == "#TM":
298
+ data = packet.parse_text_message(fields)
299
+ sender = data["sender"]
300
+ if sender != self.callsign and self.on_message:
301
+ await self.on_message(self, sender, data["message"])
302
+
303
+ # ── Server error ──
304
+ elif prefix == "$ER":
305
+ data = packet.parse_server_error(fields)
306
+ code = ServerError(data["error_code"])
307
+ err = FSDServerError(code, data["param"], data["description"])
308
+ if err.is_fatal:
309
+ logger.error(f"Fatal server error: {err}")
310
+ self._running = False
311
+ if self.on_error:
312
+ await self.on_error(self, err)
313
+
314
+ # ── Kill ──
315
+ elif prefix == "$!!":
316
+ data = packet.parse_kill(fields)
317
+ logger.warning(f"Killed by {data['sender']}: {data['reason']}")
318
+ self._running = False
319
+
320
+ # ── Ping ──
321
+ elif prefix == "$PI":
322
+ data = packet.parse_client_query(fields)
323
+ # Respond with pong
324
+ await self.send_pong(data["receiver"])
325
+
326
+ # ── #SB plane info request ──
327
+ elif prefix == "#SB":
328
+ # Silently acknowledge but we don't auto-respond
329
+ pass
330
+
331
+ # ── Client added/removed ──
332
+ elif prefix in ("#AP", "#DP", "#DA"):
333
+ # Track logins/logouts (simplified)
334
+ pass
335
+
336
+ # ── sending methods ──────────────────────────────────────────────────
337
+
338
+ async def _send(self, raw: str) -> None:
339
+ """Send a raw packet line. Raises if not connected."""
340
+ if not self.connected:
341
+ raise FSDError("Not connected and logged in")
342
+ await self._conn.send_line(raw)
343
+
344
+ async def send_position(
345
+ self,
346
+ lat: float,
347
+ lon: float,
348
+ altitude_true: int,
349
+ ground_speed: int,
350
+ pitch: float = 0.0,
351
+ bank: float = 0.0,
352
+ heading: float = 0.0,
353
+ *,
354
+ squawk: int = 0o2000,
355
+ transponder_mode: str = "N",
356
+ altitude_correction: int = 0,
357
+ on_ground: bool = False,
358
+ ) -> None:
359
+ """Send a pilot position update (@ packet).
360
+
361
+ Args:
362
+ lat: Latitude in decimal degrees
363
+ lon: Longitude in decimal degrees
364
+ altitude_true: True altitude in feet
365
+ ground_speed: Ground speed in knots
366
+ pitch: Pitch angle in degrees
367
+ bank: Bank angle in degrees
368
+ heading: Heading in degrees
369
+ squawk: 4-digit octal squawk code
370
+ transponder_mode: 'S' (standby), 'N' (mode C), 'Y' (ident)
371
+ altitude_correction: PressureAlt - TrueAlt in feet
372
+ on_ground: Whether on the ground
373
+ """
374
+ pkt = packet.build_pilot_position(
375
+ callsign=self.callsign,
376
+ transponder_mode=transponder_mode,
377
+ squawk=squawk,
378
+ network_rating=self.network_rating,
379
+ lat=lat,
380
+ lon=lon,
381
+ altitude_true=altitude_true,
382
+ ground_speed=ground_speed,
383
+ pitch=pitch,
384
+ bank=bank,
385
+ heading=heading,
386
+ altitude_correction=altitude_correction,
387
+ on_ground=on_ground,
388
+ )
389
+ await self._send(pkt)
390
+
391
+ # Store for auto-send
392
+ self._current_position = {
393
+ "lat": lat,
394
+ "lon": lon,
395
+ "altitude_true": altitude_true,
396
+ "ground_speed": ground_speed,
397
+ "pitch": pitch,
398
+ "bank": bank,
399
+ "heading": heading,
400
+ "squawk": squawk,
401
+ "transponder_mode": transponder_mode,
402
+ "altitude_correction": altitude_correction,
403
+ "on_ground": on_ground,
404
+ }
405
+
406
+ async def _auto_position_loop(self) -> None:
407
+ """Auto-send position updates at regular intervals."""
408
+ while self._running:
409
+ await asyncio.sleep(self.position_interval)
410
+ if self._current_position and self.connected:
411
+ try:
412
+ await self.send_position(**self._current_position)
413
+ except Exception as e:
414
+ logger.warning(f"Auto-position send failed: {e}")
415
+
416
+ async def send_text(self, receiver: str, message: str) -> None:
417
+ """Send a text message.
418
+
419
+ Receiver can be:
420
+ - A specific callsign (e.g. "EGLL_TWR")
421
+ - @{freq} for radio (e.g. "@22800")
422
+ - * for all, *A for all ATC, *P for all pilots
423
+ - @49999 for ATC chat room
424
+ """
425
+ pkt = packet.build_text_message(self.callsign, receiver, message)
426
+ await self._send(pkt)
427
+
428
+ async def send_radio_message(self, frequency_mhz: float, message: str) -> None:
429
+ """Send a message on a radio frequency.
430
+
431
+ Args:
432
+ frequency_mhz: Frequency in MHz (e.g. 122.800)
433
+ message: Message text
434
+ """
435
+ wire = _freq.encode(frequency_mhz)
436
+ receiver = f"@{wire}"
437
+ await self.send_text(receiver, message)
438
+
439
+ async def file_flight_plan(
440
+ self,
441
+ flight_rules: str = FlightRules.IFR,
442
+ equipment: str = "",
443
+ tas: int = 0,
444
+ dep_airport: str = "",
445
+ est_dep_time: str = "",
446
+ act_dep_time: str = "",
447
+ cruise_alt: int = 0,
448
+ dest_airport: str = "",
449
+ hrs_enroute: int = 0,
450
+ min_enroute: int = 0,
451
+ hrs_fuel: int = 0,
452
+ min_fuel: int = 0,
453
+ alt_airport: str = "",
454
+ remarks: str = "",
455
+ route: str = "",
456
+ ) -> None:
457
+ """File a flight plan to the server.
458
+
459
+ Args:
460
+ flight_rules: 'I'=IFR, 'V'=VFR, 'S'=SVFR, 'D'=DVFR
461
+ equipment: FAA/ICAO equipment suffix (e.g. "H/B772/L")
462
+ tas: True airspeed in knots
463
+ dep_airport: Departure airport ICAO
464
+ est_dep_time: Estimated departure time (HHMM)
465
+ act_dep_time: Actual departure time (HHMM)
466
+ cruise_alt: Cruise altitude in feet
467
+ dest_airport: Destination airport ICAO
468
+ hrs_enroute: Hours enroute
469
+ min_enroute: Minutes enroute (additional)
470
+ hrs_fuel: Hours of fuel
471
+ min_fuel: Minutes of fuel (additional)
472
+ alt_airport: Alternate airport ICAO
473
+ remarks: Remarks
474
+ route: Route string (SIDs, airways, STARs, etc.)
475
+ """
476
+ pkt = packet.build_flight_plan(
477
+ callsign=self.callsign,
478
+ flight_rules=flight_rules,
479
+ equipment=equipment,
480
+ tas=tas,
481
+ dep_airport=dep_airport,
482
+ est_dep_time=est_dep_time,
483
+ act_dep_time=act_dep_time,
484
+ cruise_alt=cruise_alt,
485
+ dest_airport=dest_airport,
486
+ hrs_enroute=hrs_enroute,
487
+ min_enroute=min_enroute,
488
+ hrs_fuel=hrs_fuel,
489
+ min_fuel=min_fuel,
490
+ alt_airport=alt_airport,
491
+ remarks=remarks,
492
+ route=route,
493
+ )
494
+ await self._send(pkt)
495
+ logger.info(f"Filed flight plan {dep_airport}→{dest_airport} at FL{cruise_alt // 100}")
496
+
497
+ async def send_plane_info(
498
+ self,
499
+ target: str,
500
+ equipment: str = "",
501
+ airline: str = "",
502
+ livery: str = "",
503
+ ) -> None:
504
+ """Send plane information to another client (model matching).
505
+
506
+ Args:
507
+ target: Target callsign
508
+ equipment: Aircraft ICAO (e.g. "B738")
509
+ airline: Airline ICAO (e.g. "DAL")
510
+ livery: Livery identifier
511
+ """
512
+ pkt = packet.build_sb_plane_info(
513
+ sender=self.callsign,
514
+ target=target,
515
+ equipment=equipment,
516
+ airline=airline,
517
+ livery=livery,
518
+ )
519
+ await self._send(pkt)
520
+
521
+ async def request_metar(self, icao: str) -> None:
522
+ """Request a METAR from the server.
523
+
524
+ The METAR will arrive as a $AR packet; handle it via the
525
+ message reader loop (currently logged but not specially dispatched).
526
+ """
527
+ pkt = packet.build_metar_request(self.callsign, icao)
528
+ await self._send(pkt)
529
+
530
+ async def send_ping(self, receiver: str = Recipient.SERVER) -> None:
531
+ """Send a ping (keepalive)."""
532
+ pkt = packet.build_ping(self.callsign, receiver)
533
+ await self._send(pkt)
534
+
535
+ async def send_pong(self, receiver: str = Recipient.SERVER) -> None:
536
+ """Send a pong (ping response)."""
537
+ pkt = packet.build_pong(self.callsign, receiver)
538
+ await self._send(pkt)
539
+
540
+ async def request_atis(self, atc_callsign: str) -> None:
541
+ """Request ATIS information from an ATC client.
542
+
543
+ The response comes as multiple $CR...ATIS packets handled in the
544
+ read loop.
545
+ """
546
+ pkt = packet.build_client_query(
547
+ self.callsign, atc_callsign, "ATIS"
548
+ )
549
+ await self._send(pkt)
550
+
551
+ # ── utility ──────────────────────────────────────────────────────────
552
+
553
+ def pilot_visual_range(self, altitude_ft: int) -> float:
554
+ """Calculate pilot visual range in nautical miles.
555
+
556
+ Range = 10 + 1.414 * sqrt(altitude_ft)
557
+ """
558
+ return 10.0 + 1.414 * math.sqrt(max(altitude_ft, 0))