oepnvstuff
Validate whether an open GTFS-Realtime feed actually carries real-time data (delays / actual times) for specific transit lines at a specific station, either as a one-shot check or as a long-running watcher that publishes its findings to MQTT.
Open-data GTFS-RT aggregators (such as gtfs.de) merge whatever the transit
agencies deliver. For many rural and suburban lines that is schedule only: the
feed exists, but your lines never get actual times. This tool answers "is there
real realtime for my lines at my stop?" before you build anything on top of it.
The defaults (station Blankenese, lines 1/12/22/189) are just defaults;
every parameter is overridable via CLI option or OEPNV_* environment variable.
Modules
check_realtime.py— Typer CLI: one-shot check or--watchpoll loop, console report, optional MQTT publishing.monitor.py—RealtimeMonitor: poll loop with staleness detection that dispatches typed results to pluggableon_*handlers (cycle, alert, stale, error). Console output and the MQTT bridge are just handlers.gtfs_static.py— static feed: cached download with conditional GET plus schedule indexing (StaticFeedIndex): matched stops, routes of the target lines, and the trips that belong to those lines and serve a matched stop.gtfs_realtime.py— realtime feed:RealtimeFetcher(ETag / If-Modified-Since) and protobuf parsing into aRealtimeSnapshot(TripUpdates + ServiceAlerts).mqtt_bridge.py—GtfsMqttBridge: publishes cycle and line status, departures, new alerts and stale transitions viamqttstuff.
Only mqtt_bridge.py imports mqttstuff; the core runs without a broker.
Logging (loguru, with an intercept for stdlib logging, e.g. requests and
paho-mqtt) and the startup banner are shared via somestuff.oepnvstuff.
How it works
- The static GTFS feed (planned schedule, one big zip) is downloaded once and
indexed: stops whose name contains the station query, the routes of the target
lines, and the trips of those lines that serve one of the matched stops. Next
to the cached zip a
<name>.meta.jsonsidecar stores the server'sETag/Last-Modified; every later run sends a conditional GET, so an unchanged feed costs a304round-trip instead of a re-download. - The realtime feed (
.pb, TripUpdates + ServiceAlerts) is fetched, or in--watchmode polled (GTFS-RT is not push/WebSocket). The fetcher keeps the HTTP validators between polls, so unchanged cycles are304s. - TripUpdates are matched against the target trips (
trip_id) and target lines (route_id); delay values are aggregated per line. - Staleness is detected two ways, and either marks the cycle stale: the feed's
own
FeedHeader.timestampis older than--max-ageseconds, or--stale-cyclesconsecutive cycles passed without a timestamp change.
Installation
pip install somestuff-oepnvstuff
somestuff is a PEP 420 namespace; the package is imported as
somestuff.oepnvstuff and shares the namespace with the other somestuff-*
distributions.
Usage
Console script and module invocation are equivalent:
oepnv-check-realtime
python3 -m somestuff.oepnvstuff.check_realtime
One-shot check (human-readable report, the exit code summarises the result):
oepnv-check-realtime
oepnv-check-realtime --station "Blankenese" --lines "286,488"
Both feed options also accept local file paths (handy for tests):
--static feed.zip --realtime snapshot.pb.
Exit codes: 0 realtime found for at least one line (or watch ended cleanly),
2 schedule found but no realtime (or stale abort), 3 no matching stops or
trips in the static feed, 1 technical error.
Stations, lines and --near
# which stops would match? Reads only stops.txt plus one stop_times pass and
# shows coordinates, serving lines (incl. agency) and directions per stop
oepnv-check-realtime --show-stops
# several stations at once, ';'-separated (stop names contain commas and spaces)
oepnv-check-realtime --station "Blankenese;Ellerbek" --lines "1,189,195"
# same-named stations in different towns? GTFS has no postal codes, so filter
# geographically by 'lat,lon,radius_km'; applies to the check itself as well.
# Several areas are ';'-separated and OR-combined.
oepnv-check-realtime --show-stops --station Blankenese --near 53.5633,9.8144,2
oepnv-check-realtime --near "53.5633,9.8144,2;53.647,9.892,3"
Watch mode
# poll loop, one compact status line per cycle
oepnv-check-realtime --watch --interval 180
# exit with code 2 once the feed goes stale (lets a supervisor restart it)
oepnv-check-realtime --watch --stop-on-stale
Departures
# next N departures per line+direction, with realtime delay
# ("1 Richtung S Rissen: in 2 min (+80s)")
oepnv-check-realtime --watch --departures --departures-count 2
--departures-horizon sets the look-ahead window in hours (default 48).
MQTT
oepnv-check-realtime --watch --mqtt --mqtt-host broker.example.org
mosquitto_sub -h broker.example.org -v -t 'oepnv/#'
With base_topic at its default oepnv:
| Topic | Payload |
|---|---|
oepnv/status |
per-cycle summary: feed ts/age, stale flag, per-line hit counts |
oepnv/lines/<line>/status |
one line: updates, delay_min/max/avg_s, static_trips |
oepnv/departures |
with --departures: all upcoming departures in one document (in_minutes, delay_s, realtime, stop, times); published every cycle, empty list when the board is empty |
oepnv/departures/<line>/<stop>/<direction> |
one group's departures; ASCII-simplified segments (departures/1/S_Blankenese/U_Kellinghusenstrasse) |
oepnv/alerts |
each new (deduplicated) service alert: {entity, text} |
oepnv/stale |
fired once on the transition into staleness |
Everything is published with retain=False: the broker keeps no last value and
subscribers see the live stream only. A late subscriber waits at most one poll
interval for the next status/departures cycle.
Configuration
Precedence: CLI option > real environment variable > ./oepnv.local.env (current
working directory) > oepnv.local.env next to the installed check_realtime
module. Both env files are auto-loaded via python-dotenv and never override
variables that are already set. An example env file ships in the repository, not
in the wheel:
https://github.com/vroomfondel/somestuff/tree/main/somestuff/oepnvstuff/
| Env var | CLI | Default |
|---|---|---|
OEPNV_STATIC_URL |
--static |
https://download.gtfs.de/germany/nv_free/latest.zip (URL or .zip path) |
OEPNV_REALTIME_URL |
--realtime |
https://realtime.gtfs.de/realtime-free.pb (URL or .pb path) |
OEPNV_LINES |
--lines, -l |
1,12,22,189 (comma/space separated) |
OEPNV_STATION |
--station, -s |
Blankenese (case-insensitive substring match; several stations ;-separated) |
OEPNV_NEAR |
--near |
off; lat,lon,radius_km geo filter for the matched stops, areas ;-separated (OR) |
OEPNV_ENABLE_DEPARTURES |
--departures/--no-departures |
off; report upcoming departures per line+direction |
OEPNV_DEPARTURES_COUNT |
--departures-count |
3; next N departures per line+direction |
OEPNV_DEPARTURES_HORIZON_HOURS |
--departures-horizon |
48 h look-ahead |
OEPNV_CACHE_DIR |
--cache-dir |
.gtfs_cache |
OEPNV_FORCE_REFRESH |
--force-refresh |
off; re-download the static zip even on 304 |
OEPNV_WATCH |
--watch, -w |
off |
OEPNV_INTERVAL |
--interval |
180 s, see Bandwidth below |
OEPNV_MAX_AGE |
--max-age |
120 s (0 = off) |
OEPNV_STALE_CYCLES |
--stale-cycles |
6 |
OEPNV_STOP_ON_STALE |
--stop-on-stale |
off |
OEPNV_RECENT_WINDOW |
--recent-window |
900 s; a line still counts as realtime_recent this long after its last update (0 = off) |
OEPNV_MQTT_ENABLE |
--mqtt/--no-mqtt |
off |
OEPNV_MQTT_HOST |
--mqtt-host |
mosquitto.mosquitto.svc.cluster.local |
OEPNV_MQTT_PORT |
--mqtt-port |
1883 |
OEPNV_MQTT_USER |
--mqtt-user |
anonymous |
OEPNV_MQTT_PASSWORD |
--mqtt-password |
empty |
OEPNV_MQTT_BASE_TOPIC |
--mqtt-base-topic |
oepnv |
OEPNV_MQTT_TLS |
--mqtt-tls |
off; the port is not switched automatically, TLS brokers usually listen on 8883 |
OEPNV_MQTT_TLS_CA |
--mqtt-tls-ca |
system CA store; CA certificate path |
OEPNV_MQTT_TLS_CERT |
--mqtt-tls-cert |
off; client certificate for mutual TLS (with OEPNV_MQTT_TLS_KEY) |
OEPNV_MQTT_TLS_KEY |
--mqtt-tls-key |
off; client key for mutual TLS |
OEPNV_MQTT_TLS_INSECURE |
--mqtt-tls-insecure |
off; skip hostname verification (self-signed certs), chain verification still applies |
OEPNV_VERBOSE |
--verbose, -v |
off; DEBUG logging |
--show-stops has no environment variable.
Bandwidth
OEPNV_INTERVAL is the only lever on data volume, for a reason that is not
obvious:
RealtimeFetchersends conditional requests (If-None-Match/If-Modified-Since) and gtfs.de answers them correctly with304.- gtfs.de rebuilds
realtime-free.pbonce per minute (FeedHeader.timestampalways lands on:50). With an interval of 60 s or less every poll is therefore a genuinely new body; the304is structurally unreachable, not broken. - The feed is uncompressed (no
Content-Encoding) and there is no smaller regional variant:realtime-{nv,fv,rv}.pbreturn 404, only the nationwide feed exists.
Volume therefore depends only on how many feed versions are fetched, not on how
often you poll. Polling faster than the rebuild costs nothing (the extra polls
are 304s); polling slower is the only saving. Measured over 360 cycles: about
44 MB per version (min 29, max 53).
| Interval | Versions/day | Volume/day |
|---|---|---|
| 20-60 s | 1440 | ~61 GB |
| 180 s (default) | 480 | ~20 GB |
| 300 s | 288 | ~12 GB |
| 600 s | 144 | ~6 GB |
OEPNV_MAX_AGE does not have to grow with the interval: age is
now - feed_ts at fetch time and stays below 60 s because of the per-minute
rebuild, regardless of the interval.
Kubernetes
Configure everything via env in the Deployment. Point OEPNV_CACHE_DIR at a
persistent volume so pod restarts reuse the large static zip via 304, and
combine OEPNV_WATCH=1 with OEPNV_STOP_ON_STALE=1 so a dead feed exits with
code 2 and the kubelet restarts the pod. A ready-made Deployment (watch + MQTT +
cache volume) and a Secret example for the broker credentials are in the
repository, not in the wheel:
https://github.com/vroomfondel/somestuff/tree/main/somestuff/oepnvstuff/
Library
The monitor knows nothing about consoles or brokers: it dispatches typed results to registered handlers. Add your own the same way the console report and the MQTT bridge do:
from somestuff.oepnvstuff.gtfs_realtime import RealtimeFetcher
from somestuff.oepnvstuff.gtfs_static import build_index, obtain
from somestuff.oepnvstuff.monitor import CycleResult, RealtimeMonitor
index = build_index(obtain("https://download.gtfs.de/germany/nv_free/latest.zip", ".gtfs_cache"),
["1", "189"], "Blankenese")
monitor = RealtimeMonitor(RealtimeFetcher("https://realtime.gtfs.de/realtime-free.pb"), index,
interval=180.0, stop_on_stale=True)
def my_handler(cycle: CycleResult) -> None:
for line, status in cycle.per_line.items():
print(line, status.updates, status.avg_delay)
monitor.add_cycle_handler(my_handler) # every completed cycle
monitor.add_alert_handler(print) # each new service alert (deduplicated)
monitor.add_stale_handler(print) # edge-triggered on going stale
monitor.add_error_handler(print) # fetch errors (loop keeps running)
raise SystemExit(monitor.watch()) # or: monitor.check_once()
Handler exceptions are logged and swallowed, so one misbehaving sink cannot kill the poll loop.
Data sources
The defaults are the free, registration-less feeds from gtfs.de (CC BY-SA 4.0,
https://gtfs.de/de/open-data/): static germany/nv_free/latest.zip (German
regional transit) and realtime-free.pb (TripUpdates + ServiceAlerts). Any other
GTFS / GTFS-RT pair works via --static / --realtime, for example an agency's
own feed if the open aggregate turns out to lack your lines (for HVV territory:
Geofox-GTI).
Notes
- Requires:
loguru,typer,gtfs-realtime-bindings,python-dotenv,requests,tabulate,mqttstuff. - Licensed under the LGPL, see
LICENSE.mdin the repository root.
Metadata
Release files for somestuff-oepnvstuff 0.2.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| somestuff_oepnvstuff-0.2.3.tar.gz | 51.6 kB | Without provenance | Details for somestuff_oepnvstuff-0.2.3.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| somestuff_oepnvstuff-0.2.3-py3-none-any.whl | Python 3 | none | any | Details for somestuff_oepnvstuff-0.2.3-py3-none-any.whl |
Total release size: 99.5 kB
Release files / somestuff_oepnvstuff-0.2.3.tar.gz
| Download URL | somestuff_oepnvstuff-0.2.3.tar.gz |
|---|---|
| Size | 51.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
003bfb506dc922a70400f79513ce19360f8d5e82811d183cf50e367c78846676
|
|
BLAKE2b-256 checksum How to use checksums |
8b65bea5c5a356b28dd3bd959753e865f08bdf8d87657c0e80a2088c9e028e41
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.2 {"ci":null,"cpu":"x86_64","distro":{"id":"resolute","libc":{"lib":"glibc","version":"2.43"},"name":"Ubuntu","version":"26.04"},"implementation":{"name":"CPython","version":"3.14.4"},"installer":{"name":"hatch","version":"1.18.2"},"openssl_version":"OpenSSL 3.5.5 27 Jan 2026","python":"3.14.4","system":{"name":"Linux","release":"7.2.10-custom"}} HTTPX2/2.16.0
|
Release files / somestuff_oepnvstuff-0.2.3-py3-none-any.whl
| Download URL | somestuff_oepnvstuff-0.2.3-py3-none-any.whl |
|---|---|
| Size | 47.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f05557937b2f6dacf5ee0135911e7431f5dc2ea8c9b2a7bd8076d71cb6e5d657
|
|
BLAKE2b-256 checksum How to use checksums |
ab7fa089ac9db1a6e969ac6b40faa63088c941f66361c721b2d505cbef51fa6e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.2 {"ci":null,"cpu":"x86_64","distro":{"id":"resolute","libc":{"lib":"glibc","version":"2.43"},"name":"Ubuntu","version":"26.04"},"implementation":{"name":"CPython","version":"3.14.4"},"installer":{"name":"hatch","version":"1.18.2"},"openssl_version":"OpenSSL 3.5.5 27 Jan 2026","python":"3.14.4","system":{"name":"Linux","release":"7.2.10-custom"}} HTTPX2/2.16.0
|
Security for somestuff-oepnvstuff 0.2.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.