├── .gitignore ├── LICENCE.txt ├── README.md ├── main.py ├── requirements.txt └── util ├── td.py └── trust.py /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !README.md 4 | !LICENCE.txt 5 | !requirements.txt 6 | !main.py 7 | !/util 8 | !/util/td.py 9 | !/util/trust.py 10 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT No Attribution (SPDX: MIT-0) 2 | 3 | Copyright 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 11 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 13 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python3/stomp.py TD/TRUST demo script 2 | This is a short demonstration script which outputs C-class messages from 3 | Network Rail's TD feed, or basic information from the train movements 4 | feed. 5 | 6 | It does not currently display S-class messages from TD. 7 | 8 | ## Setup (publicdatafeeds) 9 | You must [register an account](https://publicdatafeeds.networkrail.co.uk/ntrod/create-account) 10 | for the Network Rail data feeds. 11 | 12 | Once your account is verified and active, continue to the section below. 13 | 14 | ## Setup 15 | Create a file named `secrets.json` in the same directory as `main.py`. This 16 | file should consist of a JSON array containing your registered email and 17 | password. For example, if your email address was "user@example.com" and your 18 | password was "hunter2", the contents of the file would be: 19 | ```json 20 | ["user@example.com", "hunter2"] 21 | ``` 22 | 23 | Make sure you install the dependencies in requirements.txt. You can do this 24 | by opening a terminal in your local copy of this repository, and running the 25 | following commands. Using a virtual environment is good practice, but you can 26 | skip these steps if you wish. 27 | 28 | ```shell 29 | python3 -m venv venv 30 | source venv/bin/activate 31 | pip3 install -r requirements.txt 32 | ``` 33 | 34 | ## Usage 35 | Open a terminal in your local copy of this repository 36 | 37 | ```shell 38 | source venv/bin/activate 39 | 40 | # To show TD messages 41 | ./main.py --td 42 | # To show TRUST messages 43 | ./main.py --trust 44 | ``` 45 | 46 | You should now see the printed messages in your terminal. 47 | 48 | ## Durable subscriptions 49 | Durable subscriptions have certain advantages - with a durable subscription, 50 | the message queue server will hold messages for a short duration while you 51 | reconnect, which reduces the risk you'll miss messages. 52 | 53 | CACI (the contractor responsible for the Network Rail open data feeds) had 54 | previously implemented a firewall rule which banned IP addresses for an hour 55 | if an ERROR frame was issued to them; this could happen under certain 56 | circumstances with a durable subscription. This is no longer the case. 57 | 58 | For this reason, this example does not use durable subscriptions by default, 59 | although you can pass the argument `--durable` if you wish to do so. 60 | 61 | See [here](https://wiki.openraildata.com/index.php?title=About_the_Network_Rail_feeds#Durable_subscriptions_via_STOMP) 62 | for more information. 63 | 64 | ## Licence 65 | This is licensed under the "MIT No Attribution" licence, a variant of the MIT 66 | licence which removes the attribution clause. There is no obligation to provide 67 | attribution; it would nevertheless be appreciated. See LICENCE.txt for more 68 | information. 69 | 70 | # Further information 71 | * [TD](https://wiki.openraildata.com/index.php?title=TD) 72 | * [TRUST](https://wiki.openraildata.com/index.php?title=Train_Movements) 73 | * [Durable subscriptions](https://wiki.openraildata.com/index.php?title=Durable_Subscription) 74 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Standard 4 | import argparse 5 | import json 6 | from time import sleep 7 | 8 | # Third party 9 | import stomp 10 | 11 | # Internal 12 | from util import td, trust 13 | 14 | 15 | class Listener(stomp.ConnectionListener): 16 | _mq: stomp.Connection 17 | 18 | def __init__(self, mq: stomp.Connection, durable=False): 19 | self._mq = mq 20 | self.is_durable = durable 21 | 22 | def on_message(self, frame): 23 | headers, message_raw = frame.headers, frame.body 24 | parsed_body = json.loads(message_raw) 25 | 26 | if self.is_durable: 27 | # Acknowledging messages is important in client-individual mode 28 | self._mq.ack(id=headers["ack"], subscription=headers["subscription"]) 29 | 30 | if "TRAIN_MVT_" in headers["destination"]: 31 | trust.print_trust_frame(parsed_body) 32 | elif "TD_" in headers["destination"]: 33 | td.print_td_frame(parsed_body) 34 | else: 35 | print("Unknown destination: ", headers["destination"]) 36 | 37 | def on_error(self, frame): 38 | print('received an error {}'.format(frame.body)) 39 | 40 | def on_disconnected(self): 41 | print('disconnected') 42 | 43 | 44 | if __name__ == "__main__": 45 | with open("secrets.json") as f: 46 | feed_username, feed_password = json.load(f) 47 | 48 | parser = argparse.ArgumentParser() 49 | parser.add_argument("-d", "--durable", action='store_true', 50 | help="Request a durable subscription. Note README before trying this.") 51 | action = parser.add_mutually_exclusive_group(required=False) 52 | action.add_argument('--td', action='store_true', help='Show messages from TD feed', default=True) 53 | action.add_argument('--trust', action='store_true', help='Show messages from TRUST feed') 54 | 55 | args = parser.parse_args() 56 | 57 | # https://stomp.github.io/stomp-specification-1.2.html#Heart-beating 58 | # We're committing to sending and accepting heartbeats every 5000ms 59 | connection = stomp.Connection([('publicdatafeeds.networkrail.co.uk', 61618)], keepalive=True, heartbeats=(5000, 5000)) 60 | connection.set_listener('', Listener(connection)) 61 | 62 | # Connect to feed 63 | connect_headers = { 64 | "username": feed_username, 65 | "passcode": feed_password, 66 | "wait": True, 67 | } 68 | if args.durable: 69 | # The client-id header is part of the durable subscription - it should be unique to your account 70 | connect_headers["client-id"] = feed_username 71 | 72 | connection.connect(**connect_headers) 73 | 74 | # Determine topic to subscribe 75 | topic = None 76 | if args.trust: 77 | topic = "/topic/TRAIN_MVT_ALL_TOC" 78 | elif args.td: 79 | topic = "/topic/TD_ALL_SIG_AREA" 80 | 81 | # Subscription 82 | subscribe_headers = { 83 | "destination": topic, 84 | "id": 1, 85 | } 86 | if args.durable: 87 | # Note that the subscription name should be unique both per connection and per queue 88 | subscribe_headers.update({ 89 | "activemq.subscriptionName": feed_username + topic, 90 | "ack": "client-individual" 91 | }) 92 | else: 93 | subscribe_headers["ack"] = "auto" 94 | 95 | connection.subscribe(**subscribe_headers) 96 | 97 | while connection.is_connected(): 98 | sleep(1) 99 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | stomp.py==8.1.0 2 | pytz==2022.1 3 | -------------------------------------------------------------------------------- /util/td.py: -------------------------------------------------------------------------------- 1 | # Python standard 2 | from datetime import datetime 3 | 4 | # Third party 5 | from pytz import timezone 6 | 7 | TIMEZONE_LONDON: timezone = timezone("Europe/London") 8 | 9 | # TD message types 10 | 11 | C_BERTH_STEP = "CA" # Berth step - description moves from "from" berth into "to", "from" berth is erased 12 | C_BERTH_CANCEL = "CB" # Berth cancel - description is erased from "from" berth 13 | C_BERTH_INTERPOSE = "CC" # Berth interpose - description is inserted into the "to" berth, previous contents erased 14 | C_HEARTBEAT = "CT" # Heartbeat - sent periodically by a train describer 15 | 16 | S_SIGNALLING_UDPATE = "SF" # Signalling update 17 | S_SIGNALLING_REFRESH = "SG" # Signalling refresh 18 | S_SIGNALLING_REFRESH_FINISHED = "SH" # Signalling refresh finished 19 | 20 | 21 | def print_td_frame(parsed_body): 22 | # Each message in the queue is a JSON array 23 | for outer_message in parsed_body: 24 | # Each list element consists of a dict with a single entry - our real target - e.g. {"CA_MSG": {...}} 25 | message = list(outer_message.values())[0] 26 | 27 | message_type = message["msg_type"] 28 | 29 | # For the sake of demonstration, we're only displaying C-class messages 30 | if message_type in [C_BERTH_STEP, C_BERTH_CANCEL, C_BERTH_INTERPOSE]: 31 | # The feed time is in milliseconds, but python takes timestamps in seconds 32 | timestamp = int(message["time"]) / 1000 33 | 34 | area_id = message["area_id"] 35 | description = message.get("descr", "") 36 | from_berth = message.get("from", "") 37 | to_berth = message.get("to", "") 38 | 39 | utc_datetime = datetime.utcfromtimestamp(timestamp) 40 | uk_datetime = TIMEZONE_LONDON.fromutc(utc_datetime) 41 | 42 | print("{} [{:2}] {:2} {:4} {:>5}->{:5}".format( 43 | uk_datetime.strftime("%Y-%m-%d %H:%M:%S"), 44 | message_type, area_id, description, from_berth, to_berth, 45 | )) 46 | -------------------------------------------------------------------------------- /util/trust.py: -------------------------------------------------------------------------------- 1 | TOCS = {} 2 | 3 | MESSAGES = { 4 | "0001": "activation", 5 | "0002": "cancellation", 6 | "0003": "movement", 7 | "0004": "_unidentified", 8 | "0005": "reinstatement", 9 | "0006": "origin change", 10 | "0007": "identity change", 11 | "0008": "_location change" 12 | } 13 | 14 | 15 | def print_trust_frame(parsed): 16 | for a in parsed: 17 | body = a["body"] 18 | 19 | toc = a["body"].get("toc_id", '') 20 | platform = a["body"].get("platform", '') 21 | loc_stanox = "@" + body.get("loc_stanox", "") 22 | 23 | summary = "{} ({} {}) {:<13s} {:2s} {:<6s} {:3s}".format( 24 | body["train_id"], body["train_id"][2:6], body["train_id"][6], 25 | MESSAGES[a["header"]["msg_type"]], toc, loc_stanox, platform) 26 | 27 | print(summary) 28 | --------------------------------------------------------------------------------