CC Blog Projects Research & Design Hub

Running Model Trains Without Human Input

Figure 1 The Train Controller PCB.
Written by Mike Christle

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.


  • How does Digital Command Control (DCC) operate model trains?
  • How can Raspberry Pi Pico PIO modules generate DCC signals?
  • How are turnout switches controlled automatically?
  • How do Hall-effect sensors track train locations?
  • How can event-driven software prevent train collisions?
  • Raspberry Pi Pico
  • RP2040 PIO
  • MicroPython
  • Digital Command Control (DCC)
  • NMRA DCC Standards
  • Texas Instruments SN754410
  • Texas Instruments DRV5032
  • Hall-Effect Sensor
  • uasyncio
  • GPIO
  • Interrupts
  • State Machines
  • PCB Design
  • Model Railroad Automation
  • Raspberry Pi | raspberrypi.com
  • MicroPython | micropython.org
  • Texas Instruments | ti.com
  • National Model Railroad Association (NMRA) | nmra.org
  • Lockheed Martin | lockheedmartin.com

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.

Figure 1 
The Train Controller PCB.
Figure 1
The Train Controller PCB.

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:

  1. S-9.1—Defines the electrical and timing requirements for the DCC signal on the tracks.
  2. S-9.2—Defines the format of a message.
  3. 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.

Figure 2 
The DCC signal.
Figure 2
The DCC signal.

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.

Figure 3 
The train controller schematic.
Figure 3
The train controller schematic.

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.

Figure 4 
A magnetic sensor embedded in the track.
Figure 4
A magnetic sensor embedded in the track.

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/

Code and Supporting Files

PUBLISHED IN CIRCUIT CELLAR MAGAZINE • MARCH 2026 #428 – Get a PDF of the issue

— ADVERTISMENT—

Advertise Here

Keep up-to-date with our FREE Weekly Newsletter!

Don't miss out on upcoming issues of Circuit Cellar.


Note: We’ve made the Dec 2022 issue of Circuit Cellar available as a free sample issue. In it, you’ll find a rich variety of the kinds of articles and information that exemplify a typical issue of the current magazine.

Would you like to write for Circuit Cellar? We are always accepting articles/posts from the technical community. Get in touch with us and let's discuss your ideas.

Sponsor this Article
+ posts

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.

Supporting Companies

Upcoming Events


Copyright © KCK Media Corp.
All Rights Reserved

Copyright © 2026 KCK Media Corp.

Running Model Trains Without Human Input

by Mike Christle time to read: 14 min