# xp-flight — put a planned flight into X-Plane

Reference implementation for atcwatch's `flight_apply_cmd`, the command behind
the tray entry **To the Sim**. You plan a flight in ATCMatch, press the button,
and this script sets it up in a running X-Plane 12.

It is a *reference exemplar*, not a product: small enough to read in one
sitting and to change into whatever your own setup needs.

```
xp_flight.py --job                 # apply the planned flight
xp_flight.py --probe               # is my X-Plane reachable? (reads only)
xp_flight.py --probe-flight EDDF   # does a stand-less start work? (LOADS a world)
```

Python standard library only. No plugin, no `pip install`.

## Setting it up

1. **Turn the network API on.** In X-Plane: *Settings → Network → Accept
   incoming connections*. It is **off by default**, and that is the single most
   common reason this script reports "X-Plane is not running" while it plainly
   is.
2. Check the connection:
   ```
   ./xp_flight.py --probe
   ```
   Reads only, changes nothing: it reports whether the simulator answers and
   whether a world is loaded and running.
3. Copy `config.example.json` to `config.json` **in this directory** and set
   `xplane_root` to your X-Plane folder. This is required — see *Why it needs
   your X-Plane folder* below.

   Not next to atcwatch's `watchlist.json`: atcwatch owns the `config.json`
   there and rejects any field it does not know, so a setting of ours in that
   file would stop the notifier from starting. Two tools, two files.
4. In atcwatch's `config.json`, point `flight_apply_cmd` at this script:
   ```json
   "flight_apply_cmd": ["/home/you/xp-flight/xp_flight.py", "--job"]
   ```

That is enough to be put at the right airport, at a real parking stand.
Aircraft and livery need one more step — see below.

## Why it needs your X-Plane folder

X-Plane will not start a flight from an airport alone. Asked for one, it
answers:

```
400  It needs both an 'airport_id' and a 'ramp' key
```

There is no endpoint that lists an airport's stands, and stand names belong to
whichever scenery you have installed — the same EDDF is `A11` with one add-on
and something else entirely with another. So the script reads the name out of
the scenery, in the order X-Plane itself uses: `scenery_packs.ini` from the
top, first pack that knows the airport wins, and the Gateway scenery that ships
with X-Plane catches everything nobody installed by hand.

It picks a *valid* stand, not a good one: gates before tie-downs, then the
first by name, always the same one for the same airport. No occupancy check, no
jetway, no runway direction — choosing a *good* gate is a far bigger job than
this whole script. Moving is five seconds in the sim, and `--ramp NAME`
overrides the choice.

Missing `scenery_packs.ini` is a stop, not a fallback: it is X-Plane's priority
order, and without it the far likelier reading is that `xplane_root` points
somewhere that is not an X-Plane installation.

## Driven by a program instead of a click

The defaults suit a person who just pressed **To the Sim**: ask the sim once,
say in one sentence what happened, and let the exit code carry the verdict.
A program that owns the flight order needs three things more.

| | |
|---|---|
| `--wait SEC` | Keep asking while X-Plane is still coming up. Without it the sim is asked once — eight seconds of silence is an answer when a person is watching, and the wrong answer when the script was started *during* the boot. A world under construction counts as "not yet", not as a fault. |
| `--expect-id ID` | Apply the order only if it still carries this id. The stand was chosen for one particular flight; ATCMatch may write a new one into the same file while the sim spends minutes building a world, and then a different flight would be loaded at a stand that does not belong to it. |
| `--json` | Report the outcome as fields instead of a sentence. |

The waiting is an option and never a default, because the ceiling belongs to
the caller: atcwatch gives the command 120 s (`FLIGHT_APPLY_TIMEOUT_SEC`), and
a longer `--wait` would simply be cut off there.

### Why `--json` and not another exit code

Exit 0 means both "set and read back" and "posted while the sim was building a
world". That looks like a gap, and closing it would be a mistake: atcwatch
reads the exit code alone, so a separate code for "unconfirmed" would put red
failures in the tray for flights that are sitting in the simulator — which is
exactly what happened on 2026-08-25 and is why the rule exists.

So the exit code stays as it is and the finer answer goes next to it:

```json
{"posted": true, "confirmed": false, "read_back": false,
 "aircraft_requested": "Aircraft/Toliss/ToLissA319Mod/a319.acf",
 "livery_requested": "Lufthansa", "icao": "EDDS", "ramp": "A17",
 "dest_icao": "EDDF", "order_id": "…",
 "note": "X-Plane was busy loading and did not confirm; …"}
```

`confirmed` is the field to look at: false means the sim never answered the
POST, so it was very probably accepted but nothing has been read back. A caller
that records "this order is used up" should hang that on `confirmed`, not on
the exit code.

The aircraft fields say *requested*, not *set*, and they mean it: without a
confirmation nobody knows whether X-Plane took the request at all. `read_back`
is true only after something was really read — with no aircraft in the order
there is nothing to read, and a cheerful "verified" would be a lie.

A refusal is one JSON object too, so a caller reads a single channel:

```json
{"posted": false, "confirmed": false, "exit": 1,
 "error": "the order changed: … was confirmed, the file now holds … "}
```

The exit codes are the same ones as without `--json`.

`--wait` is checked between attempts: a running attempt is finished rather than
cut off, so the deadline can be overrun by one probe (up to eight seconds of
UDP plus one HTTP question). Whoever needs a hard ceiling bounds the process,
the way atcwatch already does with its 120 s.

## Aircraft and livery: `aircraft-map.json`

ATCMatch names an aircraft by an `aircraft_code` such as `A319`, `B738` or
`LU737`. That is ATCMatch's own vocabulary, and the folder it lives in differs
on every installation — so this script cannot turn a code into a path on your
disk without guessing, and a guess would put the wrong aeroplane in the sim
while still reporting success.

So you state it once. Copy `aircraft-map.example.json` to `aircraft-map.json`
next to `config.json` (this directory) and add the aircraft you actually own:

```json
{
  "A319": "Aircraft/Toliss/ToLissA319/a319.acf",
  "B738": "Aircraft/Other/B737-800X/b738.acf"
}
```

Paths are relative to the X-Plane folder, which you name once in `config.json`
beside the map.

**Family packages.** One folder can hold several models — a 737 pack with the
736 through the 739, an E-Jets pack with the E170 through the Lineage. There
the code alone names no model, because which one a paint belongs to is a
property of the paint. Use a `code/livery name` key for those; it wins over the
plain code:

```json
{ "LU737/Lufthansa D-ABIA": "Aircraft/Other/LevelUp/737_80NG.acf" }
```

### What happens when the map says nothing

Nothing bad, and nothing silent:

| Situation | What the script does |
|---|---|
| No entry for the code (the default — an empty map) | Sets the flight, and says in its one output line that model and livery were left alone. You pick the aeroplane in the sim. |
| Entry points at a file that is not there | Refuses, names the path it expected. The sim is left untouched. |
| Aircraft installed, livery folder missing | Sets the model, not the paint, and names the folder you are missing. |

It never falls back to "then just fly something else". A flight it cannot set
honestly is one it refuses.

### Generating the map from a livery database

If you already have the `liveries.db` that ATCMatch is built on, the helper
writes the map for you:

```
./make-aircraft-map.py --db ~/path/to/liveries.db --root ~/X-Plane-12
```

It maps what it can prove and reports what it cannot — a folder with several
models and no variant to tell them apart, or a paint whose name and recorded
variant point at different models. Those it leaves to you rather than picking
one.

### Or read the database directly

The map is a *copy*, and a copy is only as fresh as its last run: install an
aeroplane, forget the rerun, and this script reports "no entry" for something
your sim owns. On the machine that holds the database, skip the copy — name it
in `config.json`:

```json
{
  "xplane_root": "~/X-Plane-12",
  "liveries_db": "~/path/to/liveries.db"
}
```

Then `aircraft-map.json` is not read at all, and the answer cannot drift from
whatever else reads that database. A path that is set but does not open is
refused: a database that moved is worth being told about, and the stale map
would answer *almost* right.

Leave the field out and nothing changes — the map stays the portable statement
of where things live, and it is what the download is built around.

## While X-Plane loads

Loading dominates everything this script does. Setting a flight at the airport
you are already parked at is a matter of seconds; sending X-Plane somewhere
else makes it build a new world, which takes **two to five minutes** — and for
all of that time its web API does not answer at all, because the same main loop
serves the API and builds the scenery.

That has two consequences, and neither is a fault:

* **`X-Plane is loading, so not read back`** after a flight was set. The script
  asks the simulator what it actually loaded, and while a world is being built
  there is nothing to ask. The flight is set; the read-back simply could not
  run. This is the normal case when you change airports.
* **`X-Plane was busy loading and did not confirm`**. The request was delivered
  — X-Plane read it and then went quiet mid-load instead of replying. Look at
  the sim in a minute; the flight is almost certainly there.

If you press it *while* a world is loading, the script says so and asks you to
try again once the load has finished. It will not shout a request into a
simulator that cannot hear it.

## Exit codes

atcwatch shows the script's output either way and treats non-zero as a failure.

| Code | Meaning |
|---|---|
| 0 | Flight set |
| 1 | Refused — the order, the configuration or the map is wrong |
| 2 | The simulator is not ready (not running, hung, or paused) |
| 3 | X-Plane rejected the flight, or the read-back disagreed |

After setting a flight the script reads the aircraft **and the livery** back out
of the simulator and compares them with what it asked for — a POST that
returned success can still have produced a different aeroplane, and a silently
ignored livery is the kind of thing nobody notices until they are in the
cockpit.

The one case it cannot check is a flight posted from the start screen: X-Plane
is building the world for the next few minutes, so there is nothing to read
yet. Waiting is not an option either — atcwatch gives the command 120 seconds.
So the output line says `not verified` instead of pretending. That is the only
time exit 0 comes without a read-back.

## How it decides the sim is ready

`/api/capabilities` answers in a fraction of a millisecond even while X-Plane
is wedged, so HTTP alone cannot tell "ready" from "hung". UDP goes quiet in
that state. The script therefore subscribes briefly to the simulator clock over
UDP: no answer means not running, an answer that does not grow means paused or
hung. The start screen is a deliberate exception — there no world is loaded
yet, and setting the flight is exactly what loads one.

## Scope, honestly

This script sets an airport and a parking stand, plus a model and livery if you
have mapped them. It does **not** rate the stand (no occupancy, no ATIS runway,
no jetway check), does not read SimBrief or weather, does not start X-Plane,
and does not detect your installation — the X-Plane folder comes from the
config or the script declines to guess.

Host and port are hard-wired to loopback and are never taken from the order.
The order arrives as JSON from a web server, and a server-supplied host would
turn it into a target selector for writes into a local process.

Measured against X-Plane 12.4.3 on 2026-08-25; the numbers behind the design
decisions are in the script's own header. `/api/v3` is Laminar's version
number, not ours — a future update may change it, and then this script needs a
small edit. That is the nature of a reference exemplar.

Finding a stand took under 0.3 s when an installed scenery had the airport and
about 2 s when the 380 MB Gateway file had to be read. A wrong stand name is
refused in 27 ms with a clear error, so nothing is ever quietly parked in the
wrong place.
