SmartestHome/transit/README.md

7.0 KiB

transit

"When's the next bus?" via voice, from Phase 19 of the project plan.

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-visionhomeassistant 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 (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) 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.

# 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. stop is optional in the sentence; TRANSIT_DEFAULT_STOP covers the bare "when's the next bus" case.

Configure

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:

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.