# 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=` 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.