295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""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())
|