The vehicle feed
What the regional endpoint returns, and the quirks it carries.
# The vehicle feed
The app has exactly one data source: the AJAX endpoint that powers the public
map at <https://mapavdv.kr-vysocina.cz/>.
```
GET https://mapavdv.kr-vysocina.cz/Ajax/GetPoints
```
It answers with a JSON array, one element per vehicle currently being tracked:
```json
[
{
"id": 172923,
"lat": 49.39595413208008,
"lng": 16.366287231445312,
"text": "841334",
"delay": 5,
"finalStopName": "Byst\u0159ice n.Pern.,aut.n\u00E1dr.",
"traction": "BUS"
}
]
```
The request the app sends names the app, not a browser:
```sh
curl 'https://mapavdv.kr-vysocina.cz/Ajax/GetPoints' \
-H 'Accept: */*' \
-H 'Referer: https://mapavdv.kr-vysocina.cz/' \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'User-Agent: VdvLive/1.0 (+https://github.com/ondrej-li/vdv-live)'
```
The `Referer` and `X-Requested-With` are kept because the endpoints are the site's
own AJAX endpoints and the browser front end sends them; neither hides what the
client is. An honest `User-Agent` was verified on 22 October 2026 against all
three endpoints (`GetPoints`, `OpenInfoWindow`, `GetTimetable`): each answers
`200` with the same payload as the disguised request did, so the disguise bought
nothing and is gone. An `Accept-Language` is not sent: the feed returns the same
bytes for `en` and for `cs`, so there is no reason to claim a browser's language
list. This is what [issue #46](https://github.com/ondrej-li/vdv-live/issues/46)
asked for.
`Sec-Fetch-*`, `sec-ch-ua*`, `Connection` and `Sec-GPC` from a browser request
are not sent: `URLSession` manages connection headers itself, and the endpoint
does not require the client hint headers. The `sznlbr` session cookie the site
sets is optional and configurable through
`VehicleRequestConfiguration.sessionCookie` in case the endpoint ever starts
requiring it.
## Fields
| Field | Type | Notes |
|---|---|---|
| `id` | integer | Unique within one response. Trains and vehicles outside the public timetable use **negative** ids (for example `-2435`). |
| `lat`, `lng` | number | WGS 84 degrees. Also sent as integers when they are whole numbers. |
| `text` | string | Line code. For buses this is usually six digits: **three for the licence area and three for the line**, so `764337` is line `337` registered in licence area `764`. Train numbers (four to seven digits) and the short codes on some regional lines carry no prefix. |
| `delay` | integer | Minutes. See the caveats below. |
| `finalStopName` | string | Final stop, never empty. Placeholders such as `-1 N/a` or `5437035 N/a` mean "not known". |
| `traction` | string | `BUS`, `TRAIN` and `UNKNOWN` were observed. The app keeps `TROLLEYBUS`, `TRAM` and `FERRY` ready and maps anything else onto `UNKNOWN`. |
### Line codes and licence areas
The licence area prefix is what makes the feed's `text` look odd next to a printed
timetable. In one capture, 520 of 605 records were six-digit codes, and their
prefixes group the way the licence registers group: `358`, `603`, `764`,
`841`, `842`, `795`, `796`, `680`, `340`, `761`, `200`. `LineCode` splits them,
the app shows the line number a passenger would recognise (`337`, `815`) and
keeps the licence area (`764`, `795`) as a label on the detail card.
Pinned lines are stored as line numbers, so pinning `337` catches `764337` and
`841337` alike, while pinning `764337` is filed as `337`.
## The two endpoints behind the detail card
`/Ajax/GetPoints` has no notion of where a vehicle is in its run. The map's own
marker popup is served by two further endpoints, both of which answer with
**HTML**, not JSON.
```
GET /Ajax/OpenInfoWindow?id=<vehicle id>
GET /Ajax/GetTimetable?vehicleNumber=<vehicle id>¤tStopId=<stop id or 0>
```
`OpenInfoWindow` returns a two-column table:
```html
<tr><th>Linka</th><td>764931</td></tr>
<tr><th>Spoj</th><td>11</td></tr>
<tr><th>Bezbarierový</th><td><input type="checkbox" disabled></td></tr>
<tr><th>Zastávka</th><td>Telč,Hradecká škola</td></tr>
<tr><th>Zpoždění</th><td>0 min.</td></tr>
```
which is `Linka` (line), `Spoj` (which run of the line, e.g. the 11th service of
the day), `Bezbarierový` (a disabled checkbox, ticked for accessible vehicles),
`Zastávka` (where the vehicle last was, see below) and `Zpoždění` (delay).
`GetTimetable` returns the stop list of that run, with an arrival and a
departure column, which is what makes it possible to name the **next** stop. Its
heading repeats which run it describes:
```html
<span>Linkospoj:</span>
<span>764931 / 11</span>
<span>-- / --</span>
```
The second line is filled in when the endpoint knows the vehicle; `-- / --` is
what it answers for a vehicle it cannot place (see below).
Notes for anyone touching the parsing:
- Every non-ASCII character is escaped numerically and in hexadecimal
(`č` is `č`), so ``HTMLEntities`` decodes them.
- The accessibility label is spelled `Bezbarierový`, without the háček, while the
stop label carries full diacritics. Labels are therefore matched with
diacritics folded away rather than character by character.
- `vehicleNumber` in `GetTimetable` is a vehicle **id**, not a line, and the
site's own front end always sends `currentStopId=0`.
- **The timetable endpoint does not always answer for the vehicle it was asked
about.** It resolves the run itself from the vehicle id, and when it cannot it
either answers `Jízdní řád se nepodařilo načíst.` ("the timetable could not be
loaded") with no stops at all, or - worse - a run that belongs to something
else. Observed on the two live vehicles of line `420` (`-114` and `-203`, both
reporting traction `UNKNOWN`): both have a `Zastávka` but no usable timetable.
The app therefore compares the `Linkospoj` line and `Spoj` in the answer with
the values in the info window and drops the stop list unless they agree, and
shows `Timetable for this service is not available.` when there is none. A
wrong next stop is worse than no next stop.
- The timetable is only a bonus: without it the card still has the line, the
service number and the last reported stop.
### What "Zastávka" means
`Zastávka` is where the vehicle was **last seen** - its last known position on
the run - not the stop it is heading to. The card labels it `Last stop` for that
reason, and takes the scheduled time for it from the run's timetable when the
cross-check above confirms the run. The following entry of that run is the
`Next stop`.
## Quirks the app has to absorb
Verified against a live capture of 448 records:
- **`delay` uses `-2147483648` (`Int32.min`) as "no information"** - roughly a
third of all records. `VehicleDelay` maps that onto `.unknown`; everything
else, including negatives, is a real value. Values between `-301` and `425`
appear in the wild, so the app does not clamp them.
- **Vehicles without a position** are published with `lat: 0, lng: 0` (one
record in the capture). They would land in the Gulf of Guinea, so
`Vehicle.isLocatable` filters them out; the decoder reports how many it
dropped.
- **Placeholder destinations**: `X N/a` or `-1 N/a` instead of a stop name.
`VehicleDTO.normalizedDestination` turns those into `nil`.
- **A few records sit far outside the region** - Prague, Brno, České
Budějovice, and occasionally Italy or France while a bus runs a long distance
service. They are kept and simply fall outside the map's camera bounds.
- **The array is not guaranteed to be homogeneous.** If a single record stops
matching the shape of its neighbours, the whole response would otherwise be
lost. `VehiclePayloadDecoder` therefore retries record by record and reports
`skippedRecordCount`; the map keeps whatever could be read.
## Open question: what `delay` means for some vehicles
For buses with a normal `text` (a line number) and a real `finalStopName`,
`delay` behaves like minutes behind schedule: small values around `0`, both
signs, consistent with the site's own delay colouring.
Records with `"traction": "UNKNOWN"` behave differently. They carry bus-like
stop ids (`text: "357301"`), a placeholder destination and large, round
`delay` values (`337`, `357`, `425`). That looks more like a countdown to the
next departure than a schedule deviation, and there is no documented API to
confirm it.
Until that is settled the app shows the number as reported, prefixed with `+`,
and colours it as a delay. If it turns out to be a different quantity, only
`VehicleDelay+Presentation.swift` and the detail row need to change.