A Raspberry Pi Pico Controls Two Trains
Running two model train sets on a single set of tracks without collisions is fun but exhausting. So Mike programmed a Raspberry Pi Pico and used a Digital Command Control signal, multiple turnout switches, sensors, and control software, to run two trains with no crashes—all without any human intervention. His code and related files are included.
Lately I have been playing with a model train set. To challenge myself, I bought two sets with two engines, four turnouts, and lots of track. My goal was to run both engines at the same time, without crashing. This was fun at first, but after a while I got tired of constantly adjusting speeds and throwing switches. So I finally thought, why should I do all this work, when I can just program a computer to do it all for me?
SYSTEM REQUIREMENTS
I want to run two trains—a fast passenger train and a slow freight train. Each will have its own schedule, coordinated with the other so there are no collisions.
To accomplish this, I need four elements. First, I need to drive the Digital Command Control (DCC) signal to power and control the engines. With DCC I can control the trains, control lights, toot the whistle, and have all sorts of fun. Second, I need to control the four turnout switches to control the trains’ routes. Third, I need four sensors to monitor where the trains are on the track. Fourth, I need control software to control the trains and turnouts.
The Raspberry Pi Pico is perfect for this project. I can program the PIO module to handle all the time-critical operations and implement the control software in a powerful, high-level language. I will use the PIO module to drive the DCC signal and pulse the turnout switches. The final PCB is shown in Figure 1.
All the application software will be written in MicroPython, a compact implementation of Python 3 that includes subsets of the standard libraries and will run within 256k of code space and 16k of RAM.
POWER AND THE DCC SIGNAL
Digital Command Control was originally created in the 1990s by the National Model Railroad Association (NMRA) based on a system developed by Lenz Elektronik GmbH in the 1980s. The NMRA has all sorts of specifications for model train sets that ensure that trains from different manufactures will work on the same tracks. These specifications are all available on the NMRA website [1]. For this project, I am interested in three sections:
- S-9.1—Defines the electrical and timing requirements for the DCC signal on the tracks.
- S-9.2—Defines the format of a message.
- S-9.2.1—Defines the contents of specific messages that tell the engines what to do.
The DCC signal is a square wave with an amplitude of 7V to 24V for an N-Scale train (a scale model known for its small size—typically 1:160 ratio, and narrow, 9mm track gauge). My particular set’s manufacturer (Buchmann, though the standard means any manufacturer’s equipment should work) uses 16V. Data is encoded by varying the period of the square wave for each bit, as shown in Figure 2. A one (1) has a period of 116µs and a zero (0) is 200µs. The wide pulses on the left are 0’s and the narrow pulses on the right are 1’s. Message packets include a message Preamble consisting of 14 contiguous 1’s, an address byte, then data bytes, and finally an error detection data byte.
To drive the DCC signal, I wrote a Pico PIO routine, shown in Listing 1. This routine is part of the MicroPython source code; that’s why it looks like a Python procedure. The compiler will translate this into PIO code. Fortunately, the PIO language is well documented. The Raspberry Pi RP-2040 processor data sheet [2] has a large section on the PIO module. The MicroPython online manual has a complete description of the MicroPython version of PIO code [3] [4].
# Part of the DCC_Driver class constructor. self.pin0 = Pin(gpio, mode=Pin.OUT) self.pin1 = Pin(gpio + 1, mode=Pin.OUT) self.pin2 = Pin(gpio + 2, mode=Pin.OUT) self.pin3 = Pin(gpio + 3, mode=Pin.OUT) self.sm = rp2.StateMachine( smn, # State machine number self.dcc_gen, # PIO program freq=500_000, # PIO clock frequency set_base=self.pin0, # First contigious I/O pin )# This tells the MicroPython compiler that the next routine# is a PIO program.@rp2.asm_pio(set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW), fifo_join=rp2.PIO.JOIN_TX) # This is the PIO program.def dcc_gen(): label(‘lbl0’) set(x, 0) set(y, 8) # Pull a word from the fifo # If fifo is empty, use the zero from x register pull(noblock) # Move MSB to the x register label(‘lbl1’) out(x, 1) # Drive the DCC signal low set(pins, 10) # Test the bit in the x register jmp(not_x, ‘lbl2’) # Delay for a zero bit, then drive signal high nop() [26] set(pins, 5) [26] # Test and decrement bit counter jmp(y_dec, ‘lbl1’) jmp(‘lbl0’) # Delay for a one bit, then drive signal high label(‘lbl2’) nop() [20] nop() [26] set(pins, 5) [20] nop() [26] # Test and decrement bit counter jmp(y_dec, ‘lbl1’) jmp(‘lbl0’)
Listing 1
The PIO program for the DCC signal generator.
The DCC_Driver class constructor creates the output pins for the signals and sets up the PIO module. The PIO clock is set to 500kHz, or a 2µs period. The @rp2.asm_pio decorator tells the compiler that the next routine is a PIO program. The parameters set up the pins for output, and specify a single, eight-word input FIFO.
The digits in the square brackets specify a delay, equivalent to adding NOP instructions after the current instruction. First, it pulls a word from the FIFO. Each word has eight data bits and an end-of-message flag bit. It loops through the nine bits and outputs a short or long pulse for each bit.
The PIO program does not worry about the sync pulses, The MicroPython routine that feeds the FIFO encodes that into the first two bytes. If the FIFO is empty it gets a zero from the X register, this ensures that a square wave is always being output to power the engines.
The MicroPython routines that support the PIO program are shown in Listing 2. The add_message routine copies the message data to a half word, or 16-bit byte buffer. Then the sync pulse is added to the first two words, with the check byte calculated and added at the end.
def add_message(self, msg): “”” Add a message to the output gueue. msg - A list containing the message data. Do not leave room for the checksum. Returns the index of the message in the queue. “”” # Create a half word array. bfr = array(‘H’, [0 for _ in range(len(msg) + 3)]) # Add the preamble bfr[0] = 0x1F80 bfr[1] = 0xFF00 # Find an empty slot cnt = len(self.msgs) for idx in range(cnt): if self.msgs[idx] is None: self.msgs[idx] = bfr break # Else add to end of list else: self.msgs.append(bfr) idx = len(self.msgs) - 1 # Add data to buffer self.update_message(idx, msg) return idxdef sender(self, _): “””This function is call by the Timer.””” cnt = len(self.msgs) if self.pause or cnt == 0: return if cnt <= self.idx: self.idx = 0 if self.msgs[self.idx] is not None: self.sm.put(self.msgs[self.idx], 16) self.idx += 1
Listing 2
The MicroPython code to send messages.
The sender routine will copy the next message directly to the PIO input FIFO. The longest message for my application is 6 bytes, including the sync pulse and check byte. This will always fit in the eight-word FIFO.
If messages were longer, I would use the DMA controller to copy the data to the FIFO. This would ensure that the processor never hangs waiting for the message to be copied. The worst-case message is all zeros with 200µs per bit, or 10.8ms for a message. To ensure that each message completes before the next, so the processor never hangs, I set up a timer to call sender routine at 12ms intervals.
The Hardware Foundation
I used a Texas Instruments SN754410 Quadruple Half-H Driver, shown in the schematic in Figure 3, U1 and J1, to drive the DCC signal. This chip was designed to drive DC motors, and can drive up to 36V and 1A. The signal pairs D0A/B and D1A/B are used to drive two copies of the DCC signals. This allows me to drive the tracks at two locations, which provide a good signal on all parts of the tracks.
An important note is that you must be careful of wiring polarity. If the two sets of signals are wired with opposite polarity, it will short 16V to ground and burn out the driver.
TURNOUTS
Model train turnouts are track sections with movable rails: They guide a train from one track to another. I looked at turnouts from several manufacturers and found that most work the same way. They have tiny solenoids that are driven with either a positive or negative pulse. I decided to use the same SN754410 that I used for the DCC signal, shown as U2 and U3 in Figure 3.
I included both power and ground on J2 to support both positive and negative pulses. My set needs negative pulses, so I wired the common signal to the 16V pins and programmed a negative pulse to activate the turnout.
To control the timing of the pulses, I wrote a PIO program shown in Listing 3. This is a trivial application for a PIO program, but it saves the MicroPython code from handling critical timing. In this case, the program will block waiting for data in the FIFO.
# Part of the SwitchDriver class constructor. self.pin0 = Pin(gpio, mode=Pin.OUT, value=1) self.pin1 = Pin(gpio + 1, mode=Pin.OUT, value=1) self.pin2 = Pin(gpio + 2, mode=Pin.OUT, value=1) self.pin3 = Pin(gpio + 3, mode=Pin.OUT, value=1) self.pin3 = Pin(gpio + 4, mode=Pin.OUT, value=1) self.pin3 = Pin(gpio + 5, mode=Pin.OUT, value=1) self.pin3 = Pin(gpio + 6, mode=Pin.OUT, value=1) self.pin3 = Pin(gpio + 7, mode=Pin.OUT, value=1) self.sm = rp2.StateMachine( self.smn, # State machine number self.switch_gen, # PIO program routine freq=2_000, # PIO clock frequency out_base=self.pin0, # First contigious I/O Pin )# Tells the compiler the next routine is a PIO program.@rp2.asm_pio(out_init=(rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH, rp2.PIO.OUT_HIGH), fifo_join=rp2.PIO.JOIN_TX, out_shiftdir=rp2.PIO.SHIFT_RIGHT)# The PIO program code.def switch_gen(): # Init X to all ones mov(x, invert(null)) # Init loop counter label(‘lbl0’) set(y, 16) # Pull a word from the fifo pull(block) # Write to output pins out(pins, 8) # Delay for > 250 mSec label(‘lbl1’) nop() [31] jmp(y_dec, ‘lbl1’) # Reset pins to all ones mov(pins, x) jmp(‘lbl0’)# SwitchDriver class method to pulse a switch.def set_switch(self, n): “””Pulse a switch.””” self.bfr[0] = 0xFF ^ (1 << n) self.sm.put(self.bfr[0])
Listing 3
The PIO program for turnout switch control.
The data consists of a byte with all bits except for one set to “1.” This byte is output to the pins. Next it loops for greater than 250ms, and then writes all 1’s to the pins. This will drive a negative pulse on the selected pin.
SENSORS
My design goal was for the trains to be operated without human interaction. For this to work, the software needs to know where the trains are on the track.
I decided to use the Texas Instruments DRV5032 Hall Effect sensor (which comes in a TO-92 package) for this project. This tiny chip will output a negative pulse when it is exposed to a magnetic field. The TO-92 package is small enough that I can mount the sensors in the tracks between ties, as shown in Figure 4.
The engines have DC motors and speakers with magnets that usually activate the sensors. To make this more reliable I added small magnets to the first car after the engine. Signals S0, S1, S2 and S3 (Figure 3), are for inputting these sensor signals. J3 and J4 provide 3.3V and ground connections to power the DRV5032 chips. The Pico pins are programmed to generate an interrupt on the falling edge of these signals.
CONTROL SOFTWARE
All the source files and code for this project are available on the Circuit Cellar Article Code and Files webpage. I also included the files for a companion project called DCC_Monitor. I used this project to capture DCC messages generated by my train set maker’s controller. This was a big help in my understanding of how DCC works.
First I wrote the DCC_Driver class, which contains the low-level code to control the DCC signal. This includes the PIO code that I described in the section on the DCC signal. This class allows the application to define messages, and start and stop the DCC signal.
Then I wrote the console_main program, which accepts text commands, so I could test the DCC driver and all the hardware interfaces. The sensors in this program simply cause an interrupt that prints a message.
Because the real purpose of this project, however, was to control the trains without human input. I developed two strategies. First, I wrote a control program named auto_main that will read-in a fixed schedule and run the trains by that schedule.
Then I wrote a control program named random_main to randomly route the trains around the track while hopefully avoiding collisions. Both programs are entirely “event driven,” meaning that the main program sits idle while all the work is done in interrupt and timer routines.
Listing 4 includes a section of a schedule. Each state in the schedule is triggered by a train passing over a sensor. Each state contains a next state number and a list of actions. The delay parameter says when to perform the action, for example start ringing the bell now and stop ringing it in two seconds.
# The schedule for two states# State# 0-3 Sensor Number# 4-11 State Number# 12-15 Engine Number (0x4013, # State 1 (0x4022, # Next State ( # Actions (SET_FN, 000, 4, HORN), (SWITCH, 000, 0, STRAIGHT), (SWITCH, 500, 1, TURN), (CLR_FN, 2_000, 4, HORN), (SPEED, 2_000, 4, 40), ) ) ), (0x4022, # State 2 (0x4033, # Next State ( # Actions (SET_FN, 000, 4, BELL), (SWITCH, 000, 2, TURN), (SWITCH, 500, 3, STRAIGHT), (CLR_FN, 2_000, 4, BELL), (SPEED, 4_000, 4, 60), ) ) ),# Sensor interrupt service routinedef sensor_irq(pin): # Get the sensor number sensor_no = sensors.index(pin)# Ignore interrupts thet are too close in time itime = ticks_ms() otime = sense_times[sensor_no] dtime = ticks_diff(itime, otime) sense_times[sensor_no] = itime if dtime < 1000: return # Find the correct state for engine, state in states.items(): if sensor_no == (state & 15): # Update the state number states[engine] = actions[0] # Schedule the actions actions = events[state] for action in actions[1]: queue.append(action)# Task schedulerasync def event_loop(): while True: while len(queue) > 0: func, p1, p2, p3 = queue.pop(0) func = FUNCS[func] create_task(func(p1, p2, p3)) await sleep_ms(100)# Clear function routineasync def clr_func(delay, engine, func): # Wait so other tasks can run await sleep_ms(delay) # Update the appropriate message if 1 <= func <= 4: msg = messages[engine][2] idx = messages[engine][3] msg[1] &= ~(1 << (func - 1)) driver.update_message(idx, msg) elif 5 <= func <= 8: msg = messages[engine][4] idx = messages[engine][5] msg[1] &= ~(1 << (func - 5)) driver.update_message(idx, msg)
Listing 4
Code for the automatic control program and schedule sample.
The state number includes the engine number and the sensor number. This is to minimize the damage if the program ever misses an interrupt and loses track of where the trains are. Without knowledge of the trains’ locations or a backup routine, bad things will happen.
Listing 4 also shows the routines responsible for executing the actions for each interrupt. The sensor_irq is the interrupt service routine for all sensor interrupts. It decodes the pin to determine which sensor generated the interrupt. The sensor can be triggered by the engine’s motor.
In addition to the magnet on the first car, this will cause two interrupts with a very short delay. It keeps track of the time in milliseconds, so it can ignore interrupts that occur less than a second after the last interrupt. Then it finds the appropriate state, updates the state for this engine, and schedules the actions.
I use the scheduler found in the uasyncio library [5] to run the task routines concurrently. All the task routines are marked with the “async” keyword, this tells the compiler that this routine can be run concurrently by the scheduler. The “sleep” statements are marked with the “await” keyword. This tells the compiler that this statement will surrender to the scheduler instead of blocking.
Each task routine starts with a sleep call using the delay parameter, which allows me to start several tasks together—and they will execute at the specified time. Async routines cannot be called from non-async routines, so I use a queue to pass tasks. The sensor_irq routine will write task descriptions to the queue, then the event_loop routine will read the tasks descriptions and call all the task routines.
The random path controller was definitely the hardest to get working, and caused the most crashes during development and debugging. The code for this controller is in random_main. Then the sensor_irq routine keeps track of the time in milliseconds for the interrupt for each sensor.
I placed the sensors just before the track inputs to the turnouts. If two trains want to use the same turnout within a few seconds, the second one will be delayed, then turnout switch tasks will be scheduled to route the train on a random path.
NEXT STEPS
The one big problem with this project was dealing with the initial conditions. When I started the autonomous programs running, I needed to ensure that the trains were in the right starting positions. The sensors tell the program that there is a train at that location, not which of the two trains. It’s up to the program to monitor where each train is on the tracks.
I would like to replace these sensors with something that can read some sort of train ID—maybe an optical sensor that can read a bar code, or a color sensor—so I can place colored tape on the bottom of the engines.
Another great enhancement would be to add support for the Java Model Railroad Interface (JMRI) program [6]. This is an open-source Java program that allows users to control their trains from a PC. I wrote a simple GUI interface program for my PC called dcc_control that controls my setup. The JMRI program does a lot more. It also programs parameters into the engines.
My current setup uses four turnouts and four sensors. This leaves nine free general purpose I/O pins on the Raspberry Pi Pico module. As a result, there is room for up to nine sensors, or up to four turnouts—since turnouts require two GPIOs. For a larger setup, I might need more I/O. Several chip makers make I/O expander chips that have an I2C interface. With a few of these chips, I could control just about any model train setup. But, that’s a project for another day.
RESOURCES
Raspberry Pi | https://raspberrypi.com
MicroPython | https://micropython.org
REFERENCES
[1] National Model Railroad Association
https://www.nmra.org/index-nmra-standards-and-recommended-practices
[2]Raspberry Pi RP-2040 Datasheet
https://pip-assets.raspberrypi.com/categories/814-rp2040/documents/RP-008371-DS-1-rp2040-datasheet.pdf
[3] MicroPython Libraries. rp2 — Functionality Specific to the RP2040 https://docs.micropython.org/en/latest/library/rp2.html
[4] MicroPython Libraries. Class StateMachine – Access to the RP2040’s programmable I/O interface https://docs.micropython.org/en/latest/library/rp2.StateMachine.html
[5] MicroPython Libraries. uasyncio — Asynchronous I/O Scheduler https://docs.micropython.org/en/v1.14/library/uasyncio.html
[6] Java Model Railroad Interface | https://www.jmri.org/
PUBLISHED IN CIRCUIT CELLAR MAGAZINE • MARCH 2026 #428 – Get a PDF of the issue
— ADVERTISMENT—
—Advertise Here—
Mike Christle is an electrical engineer with experience working with embedded controllers. He is currently retired from Lockheed Martin. Mike can be reached at feedback@christle.us.





