SmartestHome/transit/server.py

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