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).
