Battery powered Pico remote designed for model trains, but highly adaptable

This project is a hand held battery powered wireless remote control. The RPi Pico W allows use of WiFi or Bluetooth. An ordinary Pico can be used with a transceiver; this is the simplest option to program. It is currently set up to control 16 train tracks on a model railway, but the user interface can easily be adapted for many other tasks. The communications targeted wireless unit that is being developed can control a combination of stepper motors (max 8), DC motors (max 16) and Servos (max 16); it is possible to double or more these maxima, for one Pico MCU. Most of the communication is via I2C or already present on the main board. The remote will have three additional buttons and be mounted in a plastic box, including batteries. The rotary encoder code is available here. Video is here. LCD1602_RGB_Module_website. LCD1602-RGB-Module-demo.zip. Fram.

The two boards: the remote control main board (PCB unpopulated) and the rotary encoder board (PCB unpopulated) are available for $15 NZD plus postage. The RGB backlit 2 x 16 display (WS-19537) can be obtained from Core-electronics (or Waveshare). Fram can be obtained from Mouser or see Ramxeed – will eventually need to change to another MB85RC256V Ramxeed product with same pinout.

#****webpage: demo with RGB1602.py****     ****download RGB1602.py etc.****
...
RGB1602_SDA = Pin(0)
RGB1602_SCL = Pin(1)
...

========================================================================
# ble_simple_peripheral.py
#print("TX", data)
========================================================================

#****remote_main.py****
# MIT License (MIT)
# Copyright (c) 2025 Microtron Ltd NZ - Stephen Eichler
# https://opensource.org/licenses/MIT

import sys
from rotary_irq_rp2 import RotaryIRQ
#from micropython import const

import time
from machine import Pin, I2C
from fram_i2c import FRAM

import RGB1602
import time  # Import the time module for delays
import bluetooth
import ble_simple_central

# Initialize LCD with 16 columns and 2 rows
lcd=RGB1602.RGB1602(16,2)

i2c = I2C(id=0, scl=Pin(1), sda=Pin(0))
fram = FRAM(i2c)

p16 = Pin(16, Pin.IN, Pin.PULL_UP)
p20 = Pin(20, Pin.IN, Pin.PULL_UP)
p18 = Pin(18, Pin.IN, Pin.PULL_UP)
p17 = Pin(17, Pin.IN, Pin.PULL_UP)
p8 = Pin(8, Pin.IN, Pin.PULL_UP)
p9 = Pin(9, Pin.IN, Pin.PULL_UP)

r = RotaryIRQ(pin_num_clk=14,
              pin_num_dt=15,
              min_val=0,
              max_val=3,
              incr=1,
              pull_up=True,
              half_step=True,
              reverse=True,
              range_mode=RotaryIRQ.RANGE_BOUNDED) # RANGE_WRAP)

rgb0 = (255,255,255)
rgb1 = (144,249,15) 
rgb2 = (255,209,0) 
rgb3 = (148,0,110) 
rgb4 = (0,255,0)
rgb5 = (255,0,0)
rgb6 = (80,80,145) 
rgb7 = (0,128,128)
rgb8 = (248,248,60)
rgb9 = (255,0,255)

rgbcolor = [rgb0, rgb1, rgb2, rgb3, rgb4, rgb5, rgb6, rgb7, rgb8, rgb9]

menuitemtext = ['Train ', ', Go ', 'Speed ', ', Dir ', '', '']

ble = bluetooth.BLE()
central = ble_simple_central.BLESimpleCentral(ble)

def setcolor(colorindex = 0, perdix = 10):
    if colorindex < 0 or colorindex > 9:
        return
    if perdix < 0 or perdix > 10:
        return
    rgb = rgbcolor[colorindex]
    lcd.setRGB(int(rgb[0] * perdix / 10), int(rgb[1] * perdix / 10), int(rgb[2] * perdix / 10))

# Set the cursor to column 0, row 0
lcd.setCursor(0, 0)
# Display the text "Microtron" on the first row
lcd.printout("Microtron")

# Set the cursor to column 0, row 1
lcd.setCursor(0, 1)
# Display the text "Hello World!" on the second row
lcd.printout("Hello World!")
setcolor(0)
time.sleep(3)

not_found = False

def on_scan(addr_type, addr, name):
    if addr_type is not None:
        print("Found peripheral:", addr_type, addr, name)
        central.connect()
    else:
        global not_found
        not_found = True
        print("No peripheral found.")

central.scan(callback=on_scan)

# Wait for connection...
while not central.is_connected():
    time.sleep_ms(100)
    if not_found:
        lcd.setCursor(0, 0)
        lcd.printout("Microtron       ")
        lcd.setCursor(0, 1)
        lcd.printout("No connection   ")
        lcd.setRGB(50,50,50)
        sys.exit()

print("Connected")

def on_rx(v):
    print("RX", v)

#central.on_notify(on_rx)

with_response = False

def updatebasemenudisplay(showmenuitem, val_basemenu, menuitemstrain, lcd, val_train):
    micopy = []
    mitxcop = []
    for index, mi in enumerate(menuitemstrain):        
        micopy.append(f'{mi:02}')
        if not showmenuitem and index == val_basemenu:
            mitxcop.append(" " * len(menuitemtext[index]))
        else:
            mitxcop.append(menuitemtext[index])
    
    micopy[0] = f'{(val_train + 1):02}'
    micopy[1] = '++' if menuitemstrain[1] == 1 else '--'
    micopy[3] = 'Fd' if menuitemstrain[3] == 1 else 'Rv'
                
    topline = mitxcop[0] + micopy[0] + mitxcop[1] + micopy[1]
    botline = mitxcop[2] + micopy[2] + mitxcop[3] + micopy[3]
    
    lcd.setCursor(0, 0)
    lcd.printout(topline)
    lcd.setCursor(0, 1)
    lcd.printout(botline)
    
def updateclickedmenudisplay(showmenuitem, val_basemenu, menuitemstrain, lcd, val_train):
    micopy = ['','','','']
    micopy[0] = f'{(val_train + 1):02}'
    micopy[1] = '++' if menuitemstrain[1] == 1 else '--'
    micopy[3] = 'Fd' if menuitemstrain[3] == 1 else 'Rv'

    for index, mi in enumerate(menuitemstrain):
        if not showmenuitem and index == val_basemenu:
            micopy[index] = '  '
        else:
            if index == 2:
                micopy[index] = f'{mi:02}' 
    
                
    topline = menuitemtext[0] + micopy[0] + menuitemtext[1] + micopy[1]
    botline = menuitemtext[2] + micopy[2] + menuitemtext[3] + micopy[3]
    
    lcd.setCursor(0, 0)
    lcd.printout(topline)
    lcd.setCursor(0, 1)
    lcd.printout(botline)

def sendwirelesscmd(cmdstr):
    print("SendCmd: " + cmdstr)
    if central.is_connected():
        try:
            v = cmdstr
            print("TX", v)
            central.write(v, with_response)
        except:
            print("TX failed")

def readwritefram64(blockid, bytearray, read):
    fram.readwrite(blockid * 64, bytearray, read)

def displayHoldScript():
    lcd.setCursor(0, 0)
    lcd.printout("Hold button A   ")
    lcd.setCursor(0, 1)
    lcd.printout("to exit pause.  ")
    setcolor(9)


val_old = r.value()
val1_old = p16.value()
val20_old = p20.value()
val18_old = p18.value()
val17_old = p17.value()
val8_old = p8.value()
val9_old = p9.value()
val_basemenu = 0
val_train = 0
valclick = False
valclick_old = False
timecount = 0
clickcountup = 100
showmenuitem = False
CmdTrainAllGoPause = False
CmdTrainAllGoPauseFlag = False
CmdTrainAllGoPauseCount = 0
ButtonSaveClicked = False
ButtonLoadClicked = False
menuitems = []
for i in range(16):
    menuitems.append([0,0,0,0])

b4 = bytearray(4)
b64 = bytearray(64)
        
while True:   
    val_new = r.value()
    val1_new = p16.value()
    val20_new = p20.value()
    val18_new = p18.value()
    val17_new = p17.value()
    val8_new = p8.value()
    val9_new = p9.value()

    if val8_old != val8_new:
        val8_old = val8_new
        if val8_new == 0:
            print("Switch 8")
            if CmdTrainAllGoPause and (ButtonLoadClicked or ButtonSaveClicked):
                ButtonLoadClicked = False
                ButtonSaveClicked = False
                print("Wrong button")
                displayHoldScript()
                continue
            

    if val9_old != val9_new:
        val9_old = val9_new
        if val9_new == 0:
            print("Switch 9")
            if CmdTrainAllGoPause and (ButtonLoadClicked or ButtonSaveClicked):
                ButtonLoadClicked = False
                ButtonSaveClicked = False
                print("Wrong button")
                displayHoldScript()
                continue


    if val17_old != val17_new:
        val17_old = val17_new
        if val17_new == 0:
            print("Switch 17")
            if ButtonLoadClicked:
                ButtonLoadClicked = False
                ButtonSaveClicked = False
                print("Wrong button")
                displayHoldScript()
                continue
            if CmdTrainAllGoPause and CmdTrainAllGoPauseFlag:
                if not ButtonSaveClicked:
                    ButtonSaveClicked = True
                    lcd.setCursor(0, 0)
                    lcd.printout("Press C again to")
                    lcd.setCursor(0, 1)
                    lcd.printout("save elsepress A")
                    setcolor(9)
                else:
                    for index, bval in enumerate(b64):
                        if index % 4 == 0:
                            b64[index] = int(index / 4)
                        else:
                            b64[index] = menuitems[int(index / 4)][int(index % 4)]
                    readwritefram64(0, b64, False)                    
                    ButtonSaveClicked = False
                    print("Menuitems saved")
                    displayHoldScript()
    if val18_old != val18_new:
        val18_old = val18_new
        if val18_new == 0:
            print("Switch 18")
            if ButtonSaveClicked:
                ButtonLoadClicked = False
                ButtonSaveClicked = False
                print("Wrong button")
                displayHoldScript()
                continue
            if CmdTrainAllGoPause and CmdTrainAllGoPauseFlag:
                if not ButtonLoadClicked:
                    ButtonLoadClicked = True
                    lcd.setCursor(0, 0)
                    lcd.printout("Press B again to")
                    lcd.setCursor(0, 1)
                    lcd.printout("load elsepress A")
                    setcolor(9)
                else:                                
                    readwritefram64(0, b64, True)
                    for index, bval in enumerate(b64):
                        print(str(bval), ", ",end="")
                        if index % 4 == 3:
                            print(" ")
                        menuitems[int(index / 4)][int(index % 4)] = bval
                    print("Menuitems loaded")
                    for index, bval in enumerate(b64):
                        if index % 4 == 0:
                            sendwirelesscmd("CmdTrain" + str(int(index / 4)))
                        elif index % 4 == 1:
                            sendwirelesscmd("CmdTrain" + str(int(index / 4)) + "Go" + str(b64[index]))
                        elif index % 4 == 2:
                            sendwirelesscmd("CmdTrain" + str(int(index / 4)) + "Speed" + str(b64[index]))
                        elif index % 4 == 3:
                            sendwirelesscmd("CmdTrain" + str(int(index / 4)) + "Dir" + str(b64[index]))                                        
                    print("Menuitems sent")
                    ButtonLoadClicked = False
                    displayHoldScript()
    
    if val20_old != val20_new:
        val20_old = val20_new
        if val20_new == 0:
            print("Switch 20")
            if not CmdTrainAllGoPause:
                sendwirelesscmd("CmdTrain" + "All" + "Go" + "Pause")
                CmdTrainAllGoPause = True
                CmdTrainAllGoPauseFlag = True
                CmdTrainAllGoPauseCount = 40
                displayHoldScript()
            elif ButtonLoadClicked or ButtonSaveClicked:
                ButtonLoadClicked = False
                ButtonSaveClicked = False
                print("Wrong button")
                displayHoldScript()
                continue
                
    if CmdTrainAllGoPause:
        if val20_new == 0:
            if CmdTrainAllGoPauseCount > 0:
                CmdTrainAllGoPauseCount -= 1
        else:
            CmdTrainAllGoPauseCount = 40
            if not CmdTrainAllGoPauseFlag:
                CmdTrainAllGoPause = False
                sendwirelesscmd("CmdTrain" + "All" + "Go" + "Unpause")
                r.set(value = 0)
                r.set(max_val = 3)
                val_basemenu = 0
                r.set(value = val_basemenu)
                valclick = False
                ButtonSaveClicked = False
        if CmdTrainAllGoPauseCount <= 0:
            CmdTrainAllGoPauseFlag = False
            lcd.setCursor(0, 0)
            lcd.printout("Release button  ")
            lcd.setCursor(0, 1)
            lcd.printout("now.            ")
            setcolor(0)
 
    if val_old != val_new:
        val_old = val_new
        print('result =', val_new)
        if not CmdTrainAllGoPause:
            if valclick:
                clickcountup = 0
                
                if val_basemenu == 0:
                    val_train = val_new 
                    menuitems[val_train][0] = val_new
                    sendwirelesscmd("CmdTrain" + str(val_train))
                elif val_basemenu == 1:
                    menuitems[val_train][1] = val_new
                    sendwirelesscmd("CmdTrain" + str(val_train) + "Go" + str(menuitems[val_train][1]))
                elif val_basemenu == 2:
                    menuitems[val_train][2] = val_new
                    sendwirelesscmd("CmdTrain" + str(val_train) + "Speed" + str(menuitems[val_train][2]))
                elif val_basemenu == 3:
                    menuitems[val_train][3] = val_new
                    sendwirelesscmd("CmdTrain" + str(val_train) + "Dir" + str(menuitems[val_train][3]))
                
    if val1_old != val1_new:
        val1_old = val1_new
        print('sw =', p16.value())
        if CmdTrainAllGoPause:
            if ButtonSaveClicked and CmdTrainAllGoPauseFlag:
                ButtonSaveClicked = False
            if ButtonLoadClicked and CmdTrainAllGoPauseFlag:
                ButtonLoadClicked = False
            displayHoldScript()
        else:
            if val1_new == 0:
                valclick = not valclick
            if valclick:
                clickcountup = 0
                if val_basemenu == 0:
                    r.set(value = 0)
                    r.set(max_val = 15)
                    r.set(value = val_train)
                if val_basemenu == 1:
                    r.set(value = 0)
                    r.set(max_val = 1)
                    r.set(value = menuitems[val_train][1])
                if val_basemenu == 2:
                    r.set(value = 0)
                    r.set(max_val = 100)
                    r.set(value = menuitems[val_train][2])
                if val_basemenu == 3:
                    r.set(value = 0)
                    r.set(max_val = 1)
                    r.set(value = menuitems[val_train][3])            
            else:
                r.set(max_val = 3)
                r.set(value = val_basemenu)

    
    if CmdTrainAllGoPause:
        time.sleep_ms(50)
        continue

      
    if timecount >= 10:
        timecount = 0
        showmenuitem = not showmenuitem
        if not valclick:
            if val_new > 3:
                val_basemenu = 3
            elif val_new < 0:
                val_basemenu = 0
            else:
                val_basemenu = val_new
              
            updatebasemenudisplay(showmenuitem, val_basemenu, menuitems[val_train], lcd, val_train)
            setcolor(val_basemenu)
        else:            
            if val_basemenu == 0:
                val_train = val_new
            if val_basemenu == 1:
                menuitems[val_train][1] = val_new
            if val_basemenu == 2:
                menuitems[val_train][2] = val_new
            if val_basemenu == 3:
                menuitems[val_train][3] = val_new
                        
            updateclickedmenudisplay(showmenuitem, val_basemenu, menuitems[val_train], lcd, val_train)            
    else:
        timecount += 1
        
    if valclick:
        if clickcountup < 200:
            clickcountup += 1
        else:
            clickcountup = 0
            valclick = False
            r.set(value = val_basemenu)
        
    time.sleep_ms(50)

Remote control unit running on batteries in a plastic box housing

Here is a video of the user interface in action

Updated version Mk2 with integrated secure cables

Here is a video of the user interface with bluetooth in action. Upload to the Pico W, bdevice.py, ble_advertising.py, ble_simple_central.py, fram_i2c.py, RGB1602.py, rotary.py, rotary_irq_rp2.py. Run remote_main.py from computer and check for errors, then upload remote_main.py as main.py and run the unit on batteries rather than USB connection to Thonny. Run ble_simple_peripheral.py from the receiving computer, connected to a Pico W; upload ble_advertising.py and run ble_simple_peripheral.py from Thonny. Start the peripheral first; the remote control can connect and disconnect repeatedly without restarting the peripheral script. Further development is under way for the peripheral receiver to interpret the commands and execute them to hardware. The most obvious hardware to go with the remote control is the 16 channel low frequency PWM board (and accessories), which is suitable for controlling DC motors, servos or simply dimmable LEDs and allow two PWM channels per stepper motor unless both stepper motor circuits can use the same power level, then it is possible to use one channel, though 4 logic outputs are still required for one two channel stepper motor. Possible educational projects are 16 sections of model railway track or a robot dog, which could be battery powered and wireless bluetooth controlled. Drill map for Systema 2L container or alternative housing, created with LibreCAD.

The main board has had a capacitor added to guard against noisy power supply connections.

PCB set available

This set of 4 PCBs for the Mk2 remote control is available for $20 NZD plus postage (including commission).