"""transit — "when's the next bus/train" and on-demand route planning for voice conversation, from docs/project-plan.md Phase 19. Two different jobs, kept as two different data paths on purpose: - /departures — next-departure lookups from the small SQLite DB `sync_gtfs.py` refreshes weekly (this repo's own code, see that file). - /plan — real A-to-B route planning, proxied straight through to a self-hosted **OpenTripPlanner** (OTP) instance, NOT reimplemented here. Journey planning (transfers, walking legs, multi-modal routing) is a genuinely hard, well-studied problem with mature open-source engines already solving it; hand-rolling one would be a bad trade against just running OTP. See "Route planning scope" in README.md for what "Austria, possibly global" actually costs to run. Published (like pantry-vision/identity), not compose-network-only (like admin-canvas) — reachable at a fixed host port so Home Assistant's `rest_command` can reach it regardless of HA's own networking mode (this stack's `homeassistant` container runs `network_mode: host`, which cannot resolve plain container DNS names — see identity/identity.env.example's identical note). Bearer-token gated the same way every other published service in this project is. """ from __future__ import annotations import json import logging import os import sqlite3 import sys import urllib.error import urllib.request from datetime import datetime from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlsplit LOG = logging.getLogger("transit") TOKEN = os.environ.get("TRANSIT_TOKEN", "") DB_PATH = Path(os.environ.get("TRANSIT_DB_PATH", "/data/transit.db")) DEFAULT_STOP = os.environ.get("TRANSIT_DEFAULT_STOP", "") # OpenTripPlanner — optional, separate opt-in from the /departures path above (see # ENABLE_TRIP_PLANNING in transit.env.example and README.md's "Route planning # scope" section for why: a real OTP graph is a meaningfully bigger data/hardware # commitment than the small filtered GTFS DB /departures uses). GraphQL path is # OTP2's documented default — VERIFY against whichever OTP version you actually run; # OTP1 uses a different REST shape entirely. OTP_URL = os.environ.get("OTP_URL", "").rstrip("/") OTP_GRAPHQL_PATH = os.environ.get("OTP_GRAPHQL_PATH", "/otp/gtfs/v1") def _db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def _active_service_ids(conn: sqlite3.Connection, today: datetime) -> set[str]: weekday_col = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"][today.weekday()] date_str = today.strftime("%Y%m%d") rows = conn.execute( f"SELECT service_id FROM calendar WHERE {weekday_col} = 1 AND start_date <= ? AND end_date >= ?", (date_str, date_str), ).fetchall() active = {r["service_id"] for r in rows} for row in conn.execute("SELECT service_id, exception_type FROM calendar_dates WHERE date = ?", (date_str,)): if row["exception_type"] == 1: active.add(row["service_id"]) elif row["exception_type"] == 2: active.discard(row["service_id"]) return active def departures(stop_query: str, limit: int) -> dict: if not DB_PATH.exists(): return {"error": "no GTFS data synced yet — run sync_gtfs.py first"} conn = _db() try: matched = conn.execute( "SELECT stop_id, stop_name FROM stops WHERE lower(stop_name) LIKE ?", (f"%{stop_query.lower()}%",), ).fetchall() if not matched: return {"error": f"no known stop matches {stop_query!r}", "stop": stop_query} stop_ids = [m["stop_id"] for m in matched] now = datetime.now() active_services = _active_service_ids(conn, now) if not active_services: return {"stop": matched[0]["stop_name"], "departures": []} now_str = now.strftime("%H:%M:%S") placeholders_stops = ",".join("?" * len(stop_ids)) placeholders_services = ",".join("?" * len(active_services)) rows = conn.execute( f""" SELECT st.departure_time, r.short_name, r.long_name, t.headsign FROM stop_times st JOIN trips t ON t.trip_id = st.trip_id JOIN routes r ON r.route_id = t.route_id WHERE st.stop_id IN ({placeholders_stops}) AND t.service_id IN ({placeholders_services}) AND st.departure_time >= ? ORDER BY st.departure_time LIMIT ? """, (*stop_ids, *active_services, now_str, limit), ).fetchall() return { "stop": matched[0]["stop_name"], "departures": [ { "time": r["departure_time"], "route": r["short_name"] or r["long_name"], "headsign": r["headsign"], } for r in rows ], } finally: conn.close() def list_stops() -> dict: if not DB_PATH.exists(): return {"stops": []} conn = _db() try: rows = conn.execute("SELECT DISTINCT stop_name FROM stops ORDER BY stop_name").fetchall() return {"stops": [r["stop_name"] for r in rows]} finally: conn.close() # A minimal, documented-shape GraphQL query for OTP2's /otp/gtfs/v1 endpoint — asks # for the first itinerary only (a voice answer wants "the way there," not five # alternatives) with each leg's mode, route, and duration. VERIFY: written against # OTP2's published schema, never run against a real instance — see README.md. _OTP_PLAN_QUERY = """ query Plan($from: String!, $to: String!, $walkSpeed: Float!) { plan( fromPlace: $from toPlace: $to numItineraries: 1 walkSpeed: $walkSpeed ) { itineraries { duration legs { mode route { shortName } from { name } to { name } duration } } } } """ # OTP's own default is ~1.33 m/s (~4.8 km/h, a brisk adult pace) — deliberately # slower here (~3.2 km/h) so walking-leg durations and connection feasibility (does # a transfer's walking leg actually make the next departure?) reflect an unhurried # real walking pace rather than a fit commuter's, per household preference. # Override with a real per-person value if this still doesn't match reality. WALK_SPEED_MPS = float(os.environ.get("WALK_SPEED_MPS", "0.9")) def plan_trip(from_place: str, to_place: str) -> dict: """`from_place`/`to_place` are OTP's own "lat,lon" or geocoded-name format — passed through as given, not resolved/geocoded here. Degrades to a clear error (never a stack trace) if OTP isn't configured or unreachable, same "degrade, don't blank" rule as every other renderer in this project. """ if not OTP_URL: return {"error": "trip planning is not configured (OTP_URL unset) — see README.md"} payload = json.dumps( { "query": _OTP_PLAN_QUERY, "variables": {"from": from_place, "to": to_place, "walkSpeed": WALK_SPEED_MPS}, } ).encode("utf-8") req = urllib.request.Request( f"{OTP_URL}{OTP_GRAPHQL_PATH}", data=payload, method="POST", headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError): LOG.warning("transit: OTP request failed", exc_info=True) return {"error": f"could not reach the trip planner at {OTP_URL}"} except ValueError: LOG.warning("transit: OTP returned non-JSON", exc_info=True) return {"error": "trip planner returned an unexpected response"} itineraries = (result.get("data") or {}).get("plan", {}).get("itineraries") or [] if not itineraries: return {"from": from_place, "to": to_place, "found": False} best = itineraries[0] return { "from": from_place, "to": to_place, "found": True, "duration_minutes": round(best.get("duration", 0) / 60), "legs": [ { "mode": leg.get("mode"), "route": (leg.get("route") or {}).get("shortName"), "from": (leg.get("from") or {}).get("name"), "to": (leg.get("to") or {}).get("name"), "duration_minutes": round((leg.get("duration") or 0) / 60), } for leg in best.get("legs", []) ], } class Handler(BaseHTTPRequestHandler): server_version = "transit/1" def log_message(self, format, *args): # noqa: A002 LOG.info("%s - %s", self.address_string(), format % args) def _authorized(self) -> bool: if not TOKEN: return False return self.headers.get("Authorization", "") == f"Bearer {TOKEN}" def _respond(self, status: HTTPStatus, payload) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): # noqa: N802 if not self._authorized(): self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"}) return split = urlsplit(self.path) params = parse_qs(split.query) if split.path == "/departures": stop = (params.get("stop", [DEFAULT_STOP])[0] or "").strip() if not stop: self._respond(HTTPStatus.BAD_REQUEST, {"error": "'stop' is required (or set TRANSIT_DEFAULT_STOP)"}) return try: limit = min(int(params.get("limit", ["5"])[0]), 20) except ValueError: limit = 5 self._respond(HTTPStatus.OK, departures(stop, limit)) elif split.path == "/stops": self._respond(HTTPStatus.OK, list_stops()) elif split.path == "/plan": from_place = (params.get("from", [""])[0] or "").strip() to_place = (params.get("to", [""])[0] or "").strip() if not from_place or not to_place: self._respond(HTTPStatus.BAD_REQUEST, {"error": "'from' and 'to' are both required"}) return self._respond(HTTPStatus.OK, plan_trip(from_place, to_place)) else: self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"}) def main() -> int: logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) if not TOKEN: LOG.error("TRANSIT_TOKEN is not set — every request will be rejected until it is.") port = int(os.environ.get("TRANSIT_PORT", "8099")) server = ThreadingHTTPServer(("0.0.0.0", port), Handler) LOG.info("transit listening on :%d (db: %s)", port, DB_PATH) try: server.serve_forever() except KeyboardInterrupt: pass return 0 if __name__ == "__main__": sys.exit(main())