Add amtrak bridge to MQTT #53
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Reference
gtfs.zone/deploy-gtfs-rt#53
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Code for script here: https://git.kcfam.us/gtfs.zone/hell-gate-bridge
This should run as an isolated sidecar and optionally deployed by
deploy-gtfs-rtSummary
Implement the full worker loop: fetch and decrypt the Amtrak live tracker API, extract vehicle positions, and publish them to MQTT in OwnTracks format so vehicle-poser can consume them. The amtrak-api repo (
~/Documents/amtrak-api) is JavaScript and is used for inspiration only — the decryption logic is reimplemented in Python.Redis is not needed here — this worker only publishes.
Relevant Context
Amtrak API (from amtrak-api reverse engineering)
Three HTTP endpoints:
GET https://maps.amtrak.com/rttl/js/RoutesList.jsonArray of route objects each with a
ZoomLevel.masterZoom = sum of all ZoomLevel values.GET https://maps.amtrak.com/rttl/js/RoutesList.v.jsonJSON object:
arr[masterZoom]→ PUBLIC_KEY (string)s[s[0].length]→ CRYPTO_SALT (hex string → bytes)v[v[0].length]→ CRYPTO_IV (hex string → bytes)GET https://maps.amtrak.com/services/MapDataService/trains/getTrainsDataEncrypted blob (text string):
Decryption:
key = PBKDF2-HMAC-SHA1(PUBLIC_KEY, CRYPTO_SALT, iterations=1000, dklen=16)private_keykey2 = PBKDF2-HMAC-SHA1(private_key, CRYPTO_SALT, iterations=1000, dklen=16)key2+CRYPTO_IV→ JSON string → parse as GeoJSON FeatureCollectionEach GeoJSON feature:
geometry.coordinates:[lon, lat]— current train positionproperties: raw Amtrak fields includingTrainNum,RouteName,Heading,ID,Speed,LastValTS,Station1..N(JSON-encoded stop data), and moreMQTT message format (from vehicle-poser)
vehicle-poser subscribes to
owntracks/+/+and expects OwnTracks location messages.Topic:
owntracks/amtrak/{train_num}(vehicle-poser parses
parts[1]→driver = "amtrak",parts[2]→trip_id = train_num)Payload:
cog: bearing in degrees (convert AmtrakHeadingstring like"NE"→45)vel: speed in km/h (AmtrakSpeedis mph → multiply by 1.60934)tst: Unix timestamp (fromLastValTSortime.time())Data model philosophy
The
amtrak.pymodule should return rich, fully-populatedTraindataclasses containing all API data (stops with scheduled/actual/estimated times, station codes, etc.) so future workers can use it without re-fetching. The MQTT publisher only reads the subset it needs.Heading → degrees conversion
Config env vars
MQTT_BROKERtcp://host:portMQTT_USERNAMEMQTT_PASSWORDPOLL_INTERVAL15ROUTE_FILTERCurrent codebase state
src/hell_gate_bridge/__init__.py: emptypython -m hell_gate_bridge.mainpyproject.tomlPhase 1: Dependencies and project skeleton
Tasks
pyproject.toml:httpx— async HTTPaiomqtt— async MQTT (already used by vehicle-poser, same pattern)cryptography— AES-128-CBC (PBKDF2 via stdlibhashlib)uv syncto update lockfilesrc/hell_gate_bridge/config.py— read all env vars, parseMQTT_BROKERwithurllib.parse.urlparse, expose typed config objectsrc/hell_gate_bridge/main.py— entry point stubGotchas
MQTT_BROKERistcp://host:port; aiomqtt needshostnameandportseparately.cryptographyneeds IV/salt asbytes; the API returns hex strings — decode withbytes.fromhex(...).Phase 2: Amtrak API client + data model
Tasks
src/hell_gate_bridge/models.py— dataclasses forStopTime,TrainStop, andTrain:src/hell_gate_bridge/amtrak.py:_get_crypto_initializers(client)— fetches both RoutesList endpoints, caches result module-level (crypto params don't change between polls)_decrypt(data_b64, password, salt, iv) -> str— PBKDF2 key derivation + AES-128-CBC decrypt_parse_feature(feature) -> Train | None— maps one GeoJSON feature to aTrain, parsing all station stops, returningNoneif geometry is missingfetch_trains(client) -> list[Train]— orchestrates fetch + two-layer decrypt +_parse_featurefor each featureGotchas
blob[:-88]andblob[-88:].[longitude, latitude]— don't swap them.nullgeometry (train not yet reporting) — skip withNone.Station1..Nproperty values are double-encoded JSON (strings containing JSON) —json.loads(train["Station1"])."CBN"(Amtrak internal, no real station data).Phase 3: MQTT publisher
Tasks
src/hell_gate_bridge/publisher.py:heading_to_degrees(heading: str) -> int | None— converts"NE"→45etc.publish_positions(client: aiomqtt.Client, trains: list[Train])— for each train, builds OwnTracks payload and publishes toowntracks/amtrak/{train_num}Phase 4: Main polling loop
Tasks
main.py:httpx.AsyncClientandaiomqtt.Client(keep both alive across polls)fetch_trains→ optional route filter →publish_positions→ sleepasyncio.run(main())+CancelledErrorhandlingMqttError(exponential backoff, capped at 60s).env.exampleto addPOLL_INTERVALandROUTE_FILTERGotchas
httpx.AsyncClientalive for the whole process — don't recreate per poll.Summary
Add
hell-gate-bridgeas an optional sidecar service indocker-compose.yml, following the same build-context / image-override pattern as the other five application repos.Relevant Context
hell-gate-bridgelives at~/Documents/hell-gate-bridgeand polls the Amtrak live tracker, publishing MQTT position messages.MQTT_USERNAME,MQTT_PASSWORD,POLL_INTERVAL,ROUTE_FILTER).MQTT_BROKERas a fulltcp://host:portURL (parsed inconfig.py).trip-updogger,vehicle-poser) connect to nanomq attcp://nanomq:1883withMQTT_USERNAME=public/MQTT_PASSWORD=public.${FOO_IMAGE:-foo:local}with an optionalbuild.contextpointing at${FOO_DIR:-../foo}.Phase 1 — Add hell-gate-bridge service
Steps
HELL_GATE_BRIDGE_DIRand a commented-outHELL_GATE_BRIDGE_IMAGEto.env.example, matching the pattern of the other four repos.hell-gate-bridgeservice todocker-compose.yml:image: ${HELL_GATE_BRIDGE_IMAGE:-hell-gate-bridge:local}build.context: ${HELL_GATE_BRIDGE_DIR:-../hell-gate-bridge}restart: on-failureMQTT_BROKER: tcp://nanomq:1883,MQTT_USERNAME: public,MQTT_PASSWORD: public,POLL_INTERVAL: 15,ROUTE_FILTER:(empty — user can override)depends_on: [nanomq]CLAUDE.mdservice map table to includehell-gate-bridge.Gotchas
ROUTE_FILTERshould be left empty (not omitted) so it's obvious to the user where to add route filtering.hell-gate-bridgedoesn't need Redis or the database — keepdepends_onminimal..env.exampledefault path../hell-gate-bridgeassumes the repo is checked out as a sibling directory, which matches the convention for all other repos but differs from the actual location (~/Documents/hell-gate-bridge). The user's.env(gitignored) can override this; the example should use the conventional relative path.