# transit

"When's the next bus?" via voice, from [Phase 19 of the project plan](../docs/project-plan.md).

Not a trip planner — no transfers, no routing. Just: for one of the household's own
configured stops (Kennelbach's own stop(s), by default), what's leaving next. Data
comes from Vorarlberg's own published GTFS static schedule (VVV/VMOBIL), sourced via
Austria's national aggregator at mobilitaetsdaten.gv.at — real, existing
infrastructure this repo reads from, not something it generates.

- **`sync_gtfs.py`** — oneshot, weekly timer (`ENABLE_TRANSIT` in
  `setup-container-host.sh`). Downloads the GTFS zip, keeps only the
  stops/routes/trips near the household's configured stop(s)
  (`GTFS_STOP_NAMES`), writes a small local SQLite DB. A full regional GTFS feed is
  large and mostly irrelevant to one household; filtering at sync time is what
  keeps lookups fast.
- **`server.py`** — always-on, bearer-token gated, `GET /departures?stop=<name>`
  returns the next few departures computed from `calendar.txt`/`calendar_dates.txt`
  (which services run today) joined against `stop_times.txt` for the matched
  stop(s). **Published**, like `identity`/`pantry-vision` — `homeassistant` runs
  `network_mode: host` in this stack and can't resolve plain container DNS names,
  so a `rest_command` needs a real published port to reach this on, same reasoning
  as `identity/identity.env.example`'s note about `HA_URL`.

## Route planning scope — "Austria, possibly global" has a real cost

`/plan` doesn't do any journey-planning itself — it proxies straight through to a
self-hosted **[OpenTripPlanner](https://www.opentripplanner.org/)** (OTP) instance,
because real A-to-B routing (transfers, walking legs, multi-modal itineraries) is a
mature, well-studied problem with good open-source engines already solving it, and
hand-rolling one here would be a bad trade.

**What OTP actually needs to answer a query is data loaded into its "graph": an OSM
map extract for the road/path network, plus every GTFS feed for the transit systems
you want it to route through.** That's the real scoping decision, not a code
feature:

- **Austria-wide** — one Austria OSM extract (a few hundred MB, e.g. from
  [Geofabrik](https://download.geofabrik.de/europe/austria.html)) plus the GTFS
  feeds for however many of Austria's regional operators you want covered
  (`mobilitaetsdaten.gv.at`'s catalog lists them all, not just VVV) — a realistic,
  moderate self-hosting commitment (single-digit GB of graph data, a build that
  takes minutes, RAM in the low single-digit GB range for OTP itself).
- **Actually global** — every country's OSM data (the full planet extract is
  **~80+ GB** compressed) plus GTFS for transit systems worldwide that publish one
  at all (many don't, or only via a paid/restricted feed) — this is not a
  "flip a flag" step up from Austria-wide, it's a full self-hosted mapping
  infrastructure project with real disk/RAM/build-time cost, and results would still
  be missing any transit system that has no public GTFS feed. **Start with Austria
  (or Austria + neighboring countries if cross-border trips matter), and treat
  "global" as a real future infrastructure decision, not a config value** — see the
  open decision in `docs/project-plan.md` §4.

`sync_gtfs.py`'s own filtered SQLite DB (for `/departures`) and OTP's graph (for
`/plan`) are two **completely separate data pipelines**, on purpose — one small and
household-specific, one general-purpose and much larger. Nothing here builds or
manages OTP's graph-build step; that's a one-time (re-run when you update the
OSM/GTFS inputs) manual operation — see OTP's own docs for the current build command
for whichever version you deploy.

## Voice: "when's the next bus"

**Nothing under this repo builds the HA-side custom-sentence/intent-script/
`rest_command` wiring** — same convention as `identity`'s voice registration and
every other HA integration point in this project.

```yaml
# configuration.yaml (excerpt) — worked example, unverified against a real instance.
intent_script:
  NextDeparture:
    speech:
      text: >
        {% if reg_result.content.departures %}
          Next from {{ reg_result.content.stop }}: {{ reg_result.content.departures[0].route }}
          at {{ reg_result.content.departures[0].time }}.
        {% else %}
          I couldn't find a departure for {{ stop | default('your stop') }}.
        {% endif %}
    action:
      - service: rest_command.transit_departures
        data:
          stop: "{{ stop | default('') }}"
        response_variable: reg_result

rest_command:
  transit_departures:
    url: "http://127.0.0.1:8099/departures?stop={{ stop | urlencode }}"
    method: GET
    headers:
      Authorization: "Bearer !secret transit_token"
```

Plus a custom sentence (`"when's the next bus"` / `"when's the next bus from
{stop}"`) mapping to `NextDeparture` — see
[HA's custom sentences docs](https://www.home-assistant.io/voice_control/custom_sentences/).
`stop` is optional in the sentence; `TRANSIT_DEFAULT_STOP` covers the bare "when's
the next bus" case.

## Configure

```sh
cp transit/transit.env.example /opt/smart-home/transit/transit.env
openssl rand -hex 32   # TRANSIT_TOKEN
chmod 600 /opt/smart-home/transit/transit.env
$EDITOR /opt/smart-home/transit/transit.env
```

`GTFS_FEED_URL` is the one thing you have to go get by hand — see the template's own
comment for where. Then run the first sync:

```sh
cd /opt/smart-home && docker compose run --rm transit-sync
```

Check the log for which stops matched `GTFS_STOP_NAMES` — if it says none matched,
the feed's own `stop_name` spelling differs from what you guessed; `GET /stops`
(once any sync has run, even a differently-filtered one) lists what's actually in
the feed near a broader search term.

## Manual verification still outstanding

1. **The exact `GTFS_FEED_URL` for VVV/VMOBIL** — confirmed the provider and the
   national aggregator exist (`docs/project-plan.md` §1.17's research note), not the
   literal `.zip` download URL, which the household has to get from the aggregator's
   own catalog page.
2. **GTFS_STOP_NAMES="Kennelbach"** — a reasonable guess at how the feed names the
   local stop(s), not confirmed against the real `stops.txt`.
3. Post-midnight trips: GTFS allows `departure_time` past `24:00:00` for a service
   day's late-night trips (e.g. `25:30:00` for 1:30 AM the next calendar day).
   `server.py`'s string comparison against the current `HH:MM:SS` handles same-day
   departures correctly but does **not** special-case this — a departure logged as
   `25:30:00` will never match a same-day query after midnight has actually passed.
   Rare for a village bus stop, not fixed here.
4. Whether VVV's feed includes GTFS-realtime (delays/cancellations) — this only
   reads static schedule data; if a realtime feed exists it isn't consumed, so
   answers are "scheduled," not "actual."
5. Whether `mobilitaetsdaten.gv.at`'s feed structure matches plain GTFS exactly (all
   the standard files, no unusual encoding) — parsed against the documented GTFS
   spec, not a downloaded copy of this specific feed.
