Speech is silver, silence is golden, they say… Now that we have figured out how to communicate with the Roco Z21 DCC command station with a Python script (see here), it would be very enticing to figure out how to actually play with a locomotive. But let’s hold our horses for a moment, for two specific reasons:
- Blind: unlike with manual control where we have visual feedback on where the locomotive is on the track and foreseeing potential problems, an automated script is blind. If we see that a locomotive is approaching a tight curve with too high speed or it approaches the end of the track, the script has no knowledge of this and won’t slow down or stop the locomotive. We can see a (model?) train wreck happen in front of our eyes if we don’t have an emergency stop button nearby.
- Damage: like in real life where the consequences of a train wreck are usually severe, the same is true for a model railroad. The damage won’t be as drastic in terms of money or even loss of life, but still it would be rather heart breaking to see a delicate model of over €300 getting severe damage. Especially if it is your favorit model and it is no longer in production.
The only information a program can get about what happens on the layout is a series of messages from the Z21. These can be responses from our own commands, but also other events like responses on commands from other throttles. (Remember that multiple throttles can control multiple locomotives at the same time.) So before we will even begin setting a locomotive in motion we have to make sure that the program can handle any relevant message that it receives from the Z21.
The Z21 can send all ‘throttle information’ in terms of status updates for locomotives, switches and decouplers (X-bus) as well as feedback information from the layout about e.g. occupancy of sections (R-bus), across across the (wireless) network. To actually have the Z21 sending this information to our program, our program has to subscribe to this information. The subscription will time out after 60 seconds and any new command will reset this counter.
So in order to listen to the Z21 we will create a connection, send an initial command for subscribing and start capturing and interpreting all incoming messages. Normally we would need to create a separate task that repeats this command to refresh the subscription within every 60 seconds to keep it alive. But for now we will live with the time limit and simply capture all messages as long as the Z21 sends them.
Establish a connection
No news here:
import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Internet, UDP Z21 = ('192.168.0.111', 21105)
Subscribe to messages from the Z21
Create a subscription using the LAN_SET_BROADCASTFLAGS command (see section 2.16 in the protocol document). The format of this command is:
- DataLen (2 bytes little endian): 0x08, 0x00
- Header (2 bytes little endian): 0x50, 0x00
- Data (32 bits little endian): Broadcast Flags
The Broadcast Flags specify which data we actually would like to receive. Each bit has its own meaning and can be True (yes we want to get this information) or False. For now we want to receive all LAN_X_LOCO_INFO messages (0x00000001), for all locomotives on the track (0x00010000). We’ll simply combine these values using a logical OR to get everything properly in the Data section of the command. We’ll let Python handle all that, including the little endian byte order.
# 2.16 LAN_SET_BROADCASTFLAGS dataLen = int.to_bytes(0x0008, 2, byteorder = 'little') header = int.to_bytes(0x0050, 2, byteorder = 'little') data = int.to_bytes(0x00010001, 4, byteorder = 'little') command = dataLen + header + data s.sendto(command, Z21)
We already solved how to receive entire UDP packets from the Z21.
# infinite loop (Z21 will stop sending after 60 seconds) while True: incomingPacket, sender = s.recvfrom(1024) if sender != Z21: continue # ignore message SlicePacket(incomingPacket)
But we assumed that a packet contains only one single message. Since this may not be the case, we’ll add a function that extracts all messages by slicing an incoming UDP packet. All messages in a packet are simply added to each other and since the first 2 bytes always indicate the length of the message, it is easy to isolate the first message from the rest. Then the rest can be handled likewise, until there is no rest left.
The following function picks up a packet and isolates all messages:
def slicePacket(data): while len(data) > 0: dataLen = int.from_bytes(data[0:2], byteorder='little') handleMessage(data[0:dataLen]) data = data[dataLen:]
Last but not least, we’ll need to interpret the message. In this case, we only concentrate on LAN_X_LOCO_INFO messages. The format of this message type is:
- DataLen (2 bytes little endian): (any value between 14 and 21), 0x00
- Header (2 bytes little endian): 0x40, 0x00
- X-Header (1 byte): 0xEF
- DB0 … DBn: locomotive information
- XOR-byte (integrity check for X-Header and all DB bytes)
Note: the format of DataLen, Header, Data is for all Z21 messages. The format for the Data section broken into X-Header, DB bytes and the final XOR-byte is for all XPressNet messages. These message types are identified by 0x40 in the third byte and the LAN_X_ prefix in the names. Note also that all data in XPressNet messages are in big endian rather than little endian for the rest of the Z21 data.
To process only messages of the LAN_X_LOCO_INFO type, we only look for messages that have the values of 0x40 in the third byte and 0xEF in the fifth byte. We also ignore all other bytes except the DB bytes. We could evaluate the XOR-byte to verify the integrity of the message, but we’ll do that another time.
For now, I’d like to have reported:
- The locomotive address
- The direction
- The speed
- The number of speed steps (14, 28 or 128 steps)
- The status of the main light (F0)
The code for this could look like this:
def handleMessage(message): if message != 0x40 or message != 0xEF: return print('LAN_X_LOCO_INFO message received') db = message[5:] # address address = int.from_bytes(db[0:2], byteorder = 'big') print('Address: ', address) # direction direction = db & 0b10000000 if direction != 0: print('Direction: forward') else: print('Direction: backward') # speed speed = db & 0b01111111 print('Speed: ', speed) # speed steps speedSteps = db & 0b00000111 if speedSteps == 4: print('Speed steps: 128') elif speedSteps == 2: print('Speed steps: 28') else: print('Speed steps: 14') # F0 f0 = db & 0b00010000 if f0 != 0: print('F0 (main lights): ON') else: print('F0 (main lights): OFF')
I tried the script with a test setup of my z21 with its Wifi base station, a simple piece of straight track and my trusty locomotive. For controlling the locomotive I used the Multimaus. I could also have used the Z21 app on my iPhone or iPad.
Time to test
When using the Multimaus, I got the following output (example):
LAN_X_LOCO_INFO message received Address: 22 Direction: forward Speed: 2 Speed steps: 128 F0 (main lights): ON LAN_X_LOCO_INFO message received Address: 22 Direction: forward Speed: 0 Speed steps: 128 F0 (main lights): ON
With this kind of information a program can detect that e.g. the speed is too high for a specific section. It can then issue commands to adjust the speed. Also, knowing of two locomotives on a collision course, it can automatically switch off the power on the track.
Of course the script would need more information about where the locomotive is on the track. We will use occupancy detectors and other feedback techniques beyond DCC later on for that. Also, the script will need to know what the status of switches is so that it can change them to set up the correct route for the train. In other words, plenty of possibilities to expand on this basic functionality.
Below is the full listing of this program with an extended version of the handleMessage function. This version will isolate all relevant information from this message type, but I stripped all print statements. This version could be the start of a dedicated handler function for only this message type. We could create other handlers for other message types.
import socket def slicePacket(data): while len(data) > 0: dataLen = int.from_bytes(data[0:2], byteorder='little') handleMessage(data[0:dataLen]) data = data[dataLen:] def handleMessage(message): if message != 0x40 or message != 0xEF: return db = message[5:] address = int.from_bytes(db[0:2], byteorder = 'big') speedSteps = db & 0b00000111 direction = db & 0b10000000 speed = db & 0b01111111 doubletraction = db & 0b01000000 smartsearch = db & 0b00100000 f0 = db & 0b00010000 f1 = db & 0b00000001 f2 = db & 0b00000010 f3 = db & 0b00000100 f4 = db & 0b00001000 f5 = db & 0b00000001 f6 = db & 0b00000010 f7 = db & 0b00000100 f8 = db & 0b00001000 f9 = db & 0b00010000 f10 = db & 0b00100000 f11 = db & 0b01000000 f12 = db & 0b10000000 f13 = db & 0b00000001 f14 = db & 0b00000010 f15 = db & 0b00000100 f16 = db & 0b00001000 f17 = db & 0b00010000 f18 = db & 0b00100000 f19 = db & 0b01000000 f20 = db & 0b10000000 f21 = db & 0b00000001 f22 = db & 0b00000010 f23 = db & 0b00000100 f24 = db & 0b00001000 f25 = db & 0b00010000 f26 = db & 0b00100000 f27 = db & 0b01000000 f28 = db & 0b10000000 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) Z21 = ('192.168.0.111', 21105) # 2.16 LAN_SET_BROADCASTFLAGS dataLen = int.to_bytes(0x0008, 2, byteorder = 'little') header = int.to_bytes(0x0050, 2, byteorder = 'little') data = int.to_bytes(0x00010001, 4, byteorder = 'little') command = dataLen + header + data s.sendto(command, Z21) while True: incomingPacket, sender = s.recvfrom(1024) if sender != Z21: continue # ignore message slicePacket(incomingPacket) s.close();