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 +102 -0
- fsd9/client.py +558 -0
- fsd9/connection.py +229 -0
- fsd9/constants.py +219 -0
- fsd9/errors.py +51 -0
- fsd9/frequency.py +95 -0
- fsd9/packet.py +845 -0
- fsd9/pbh.py +93 -0
- fsd9lib-0.1.0.dist-info/METADATA +160 -0
- fsd9lib-0.1.0.dist-info/RECORD +13 -0
- fsd9lib-0.1.0.dist-info/WHEEL +5 -0
- fsd9lib-0.1.0.dist-info/licenses/LICENSE +17 -0
- fsd9lib-0.1.0.dist-info/top_level.txt +1 -0
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))
|