Subscribes to an OwnTracks MQTT location stream and writes GTFS-RT Trip Updates to Redis
  • Python 96.3%
  • Dockerfile 2.6%
  • Makefile 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Max Katz-Christy 9d382bb58c
All checks were successful
Build / build (push) Successful in 24s
ci: use DEPLOY_USER and DEPLOY_TOKEN for registry auth
- Rename REGISTRY_USER to DEPLOY_USER and REGISTRY_TOKEN to DEPLOY_TOKEN to
  match the shared gtfs.zone credential naming
2026-08-18 02:23:40 +02:00
.forgejo/workflows ci: use DEPLOY_USER and DEPLOY_TOKEN for registry auth 2026-08-18 02:23:40 +02:00
src/trip_updogger chore: remove em-dashes from docs and comments 2026-08-04 19:52:44 +02:00
tests chore: remove em-dashes from docs and comments 2026-08-04 19:52:44 +02:00
.copier-answers.yml docs: update copier 2026-04-29 01:47:52 +02:00
.env.example fix: publish timed per-stop predictions, not a bare delay 2026-08-02 21:51:29 +02:00
.gitignore chore: copier update to v0.1.0 2026-04-17 21:56:41 +02:00
.mcp.json chore: copier update to v0.1.0 2026-04-17 21:56:41 +02:00
.pre-commit-config.yaml build: default pre-commit 2026-03-10 15:02:53 +01:00
CLAUDE.md chore: remove em-dashes from docs and comments 2026-08-04 19:52:44 +02:00
Dockerfile chore: copier update to v0.1.0 2026-04-17 21:56:41 +02:00
LICENSE.txt docs: add agpl-3 2026-03-10 14:44:44 +01:00
Makefile chore: apply bare-chassis copier template 2026-04-17 00:47:38 +02:00
pyproject.toml refactor: turn trip-updogger into a Redis->Redis worker 2026-07-25 01:29:02 +02:00
README.md chore: remove em-dashes from docs and comments 2026-08-04 19:52:44 +02:00
uv.lock refactor: turn trip-updogger into a Redis->Redis worker 2026-07-25 01:29:02 +02:00

trip-updogger

Tiny async Python worker that turns live vehicle positions into GTFS-RT Trip Updates in Redis.

Part of a larger stack; see music-student for the full deployment.

How it fits together

Traccar Client app (phone) ─┐
                            ├─> vehicle:{tracker_id}:* (Redis, DB 1)
hell-gate-bridge ───────────┘        │
   (via cafe-car /ingest)            │ watch (poll every POLL_INTERVAL)
                                trip-updogger (this worker)
                                     │ project onto the schedule, predict every stop ahead
                                     └─> trip_update:{trip_id} (300s TTL)
                                              └─> cafe-car (serves GTFS-RT)

Positions are already in Redis: vehicle-poser writes them for the Traccar path, and cafe-car's /ingest/position writes them for hell-gate-bridge. This worker sweeps those vehicle:* keys, and for each new fix it loads the trip's scheduled stop times from PostgreSQL, projects the fix onto the stop polyline to measure the vehicle's delay against the schedule, and writes a Trip Update to Redis with a 300-second TTL.

The prediction covers every stop from the one ahead to the end of the trip, each with an absolute epoch arrival/departure time as well as a delay. The vehicle is assumed to hold its current lateness for the rest of the trip; without dwell or running-time estimates that constant-delay model is the only honest one available. The absolute times are the point: a consumer cannot order a trip's stops from delays alone, so an untimed update says nothing about where the vehicle is, and vehicles from the Traccar path carry no current_stop_sequence or stop_id for it to fall back on.

Schedule times are resolved against the service day, not the fix's calendar date. GTFS times run past 24:00:00, so a 01:00 fix on a trip scheduled 23:0025:30 belongs to the previous day's run.

The trip_id is taken straight from the position record. Vehicle-poser resolves it from the schedule rules and hell-gate supplies it directly, so this worker never re-resolves it.

Collision guard: the worker writes only when the trip_update:* slot is empty or already carries its own source stamp. A record from any other producer (hell-gate-bridge's richer per-stop prediction, built from real observed ETAs rather than propagated schedule delay) is left untouched. Keying off the source stamp rather than the shape of the record means that once a richer producer owns a key, this worker defers for the record's lifetime instead of flip-flopping with it every poll.


Redis contract

Reads vehicle:{tracker_id}:{trip_slug} records (written by vehicle-poser / cafe-car /ingest):

{"tracker_id": "...", "trip_id": "...", "lat": 51.5, "lon": -0.1, "timestamp": 1234567890, "start_date": "20260724"}

tracker_id is the device's secret id; it is never exposed in a feed. cafe-car labels vehicles by the tracker's nickname.

Writes trip_update:{trip_id} (or trip_update:{trip_id}:{start_date} when the position carries a start_date, matching cafe-car's ingest key scheme):

{
  "trip_id": "...",
  "tracker_id": "...",
  "timestamp": 1234567890,
  "stop_time_updates": [
    {"stop_sequence": 5, "stop_id": "...", "arrival_time": 1234568000, "arrival_delay": 42,
     "departure_time": 1234568060, "departure_delay": 42}
  ],
  "delay": 42,
  "stop_sequence": 5,
  "source": "trip-updogger"
}

stop_time_updates is the payload, one entry per stop from the one ahead to the end of the trip, with absolute epoch times. Its field names match cafe-car's ingest contract, the same one hell-gate-bridge writes. Top-level delay and stop_sequence duplicate the head of that list; they are advisory, kept so a redis-cli GET stays readable, and cafe-car reads the list whenever it is non-empty.

delay is in seconds (positive = late, negative = early). tracker_id is the device's secret credential, held for internal reference only. It is never published. cafe-car labels the vehicle in the public feed by the producer's vehicle_id if the position record carried one, else the tracker's nickname.


Environment variables

Variable Example Description
REDIS_URL redis://redis:6379/1 Redis connection URL including DB number
DATABASE_URL postgresql+psycopg2://.../postgres PostgreSQL connection URL
POLL_INTERVAL 5 Seconds between sweeps of the vehicle:* keyspace (default 5)

REDIS_URL and DATABASE_URL are required. The worker exits with KeyError if either is missing.


Running

# Install dependencies (Python 3.13, uv)
uv sync

# Run locally (requires Redis with vehicle:* keys and PostgreSQL with GTFS data)
REDIS_URL=redis://localhost:6379/1 \
  DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/postgres \
  uv run python -m trip_updogger.main

# Build and push Docker image (requires clean, pushed branch)
make push

Testing

# Seed a position the worker will pick up (trip_id must have scheduled stop_times)
redis-cli -n 1 SET vehicle:gently-tender-oyster:TRIP1 \
  '{"tracker_id":"gently-tender-oyster","trip_id":"TRIP1","lat":51.5,"lon":-0.1,"timestamp":1784808000}'

# After one poll interval, verify the computed Trip Update
redis-cli -n 1 GET trip_update:TRIP1

The schedule math (projection, service-day resolution, prediction building) is pure and unit-tested:

uv run pytest