Integration of Pi Pico into the Donkeycar pin ecosystem
Because PiGPIO is not supported on RPi 5 any longer we have to move the pin support for realtime pin tasks to the Pi Pico. This allows to migrate PWM input pins onto the Pico for using an RC controller to drive the car, as well as using the Pico's PWM pins to provide output pins for the servo and ESC. Other pins that required realtime signals like odometery or IR receivers can also be migrated to the Pico because we also support a PulseIn pin. In addition we aim to add support for the 4 analog pins to the pico, which for example could be used to monitor the battery voltage.
Changes in the pins module
The following new pins will be added:
class InputPinPico
class OutputPinPico
class InputPwmPinPico
class PwmPinPico
class PulseInPico
class AnalogInPico
The pico serial communicator
The following new class will be added. This is not a DonkeyCar part, but a low-level serial communication link between the above mentioned pins and the Pico board, connected to the RPi. It will send serial data as fast as possible back and forth between the Pico and the internal pin cache of the car, such that signals can be transferred at much higher rates than the car frequency. This class runs the serial connection in its own thread.
class Pico:
"""
Pi Pico class to communicate with the Pico over usb. We use the same usb
cable for power and data. The Pico is connected to the computer and can
handle file copy / repl on /dev/ttyACM0 while doing bidirectional data
transfer on /dev/ttyACM1.
To set up the pins on the pico, we need to send a configuration dictionary.
An example is here:
pin_configuration = {
'input_pins': {
'GP13': dict(mode='INPUT', pull_up=False),
'GP18': dict(mode='PULSE_IN', maxlen=8, auto_clear=True),
'GP16': dict(mode='PULSE_IN', maxlen=4),
'GP17': dict(mode='PWM_IN', duty=0.09),
'GP28': dict(mode='ANALOG_IN'),
},
'output_pins': {
'GP14': dict(mode='OUTPUT', value=0),
'GP15': dict(mode='PWM', frequency=60, duty_cycle=0.06),
},
}
The dictionary is required to have either 'input_pins' or 'output_pins'
or both as keys. Input and output refers to the pins on the pico.
The values of 'input_pins' and 'output_pins' contain dictionaries with
the pin name as key and the pin configuration as value. The pin
configuration is a dictionary with key-values depending on the mode of
the pin. The 'mode' key is required for all pins. The different supported
input modes are:
'INPUT': for digital input
'PULSE_IN': for pulse input
'PWM_IN': for pwm input from an RC controller
'ANALOG_IN': for analog input
The different output modes are:
'OUTPUT': for digital output
'PWM': for pulse width modulation output
See above examples for the required keys for each mode.
"""
def __init__(self, port: str = '/dev/ttyACM1'):
"""
Initialize the Pico communicator.
:param port: port for data connection
"""
self.serial = serial.Serial(port, 115200)
self.counter = 0
self.running = True
self.pin_configuration = dict()
self.send_dict = dict()
self.receive_dict = dict()
self.lock = Lock()
self.start = None
logger.info(f"Pico created on port: {port}")
# send the initial setup dictionary to clear all pins
pack = json.dumps(dict(input_pins={}, output_pins={})) + '\n'
self.serial.write(pack.encode())
self.t = Thread(target=self.loop, args=(), daemon=True)
self.t.start()
def loop(self):
"""
Donkey parts interface. We are sending newline delimited json strings
and expect the same in return.
"""
# clear the input buffer
self.serial.reset_input_buffer()
self.start = time.time()
# start loop of continuous communication
while self.running:
try:
pack = None
with self.lock:
pack = json.dumps(self.send_dict) + '\n'
self.serial.write(pack.encode())
time.sleep(0)
bytes_in = self.serial.read_until()
time.sleep(0)
str_in = bytes_in.decode()[:-1]
received_dict = json.loads(str_in)
with self.lock:
self.receive_dict.update(received_dict)
if self.counter % 1000 == 0:
logger.debug(f'Last sent: {pack}')
logger.debug(f'Last received: {str_in}')
except ValueError as e:
logger.error(f'Failed to load json in loop {self.counter} '
f'because of {e}. Expected json, but got: '
f'+++{str_in}+++')
except Exception as e:
logger.error(f'Problem with serial input {e}')
self.counter += 1
logger.info('Pico loop stopped.')
def write(self, gpio: str, value: float or int) -> None:
"""
:param gpio: the gpio pin to write to
:param value: the value to write
"""
# Wait until threaded loop has at least run once, so we don't have to)
# process None values. This blocks until the first data is received.
while self.counter == 0:
time.sleep(0.1)
assert gpio in self.send_dict, f"Pin {gpio} not in send_dict."
with self.lock:
self.send_dict[gpio] = value
def read(self, gpio):
"""
:param gpio: the gpio pin to read from
:return: the value of the pin
"""
# Wait until threaded loop has at least run once, so we don't have to
# process None values. This blocks until the first data is received.
while self.counter == 0:
time.sleep(0.1)
with self.lock:
if gpio not in self.receive_dict:
msg = (f"Pin {gpio} not in receive_dict. Known pins: "
f"{', '.join(self.receive_dict.keys())}")
logger.error(msg)
raise RuntimeError(msg)
return self.receive_dict[gpio]
def stop(self):
logger.info("Stopping Pico communication.")
self.running = False
time.sleep(0.1)
self.t.join()
logger.info("Pico communication stopped.")
self.serial.close()
total_time = time.time() - self.start
logger.info(f"Pico communication disconnected, ran {self.counter} "
f"loops, each loop taking "
f"{total_time * 1000 / self.counter:5.1f} ms.")
def setup_input_pin(self, gpio: str, mode: str, **kwargs) -> None:
"""
:param gpio: the gpio pin to set up
:param mode: the mode of the pin
:param kwargs: additional arguments for the mode
"""
assert mode in ('INPUT', 'PULSE_IN', 'ANALOG_IN', 'PWM_IN'), \
f"Mode {mode} not supported for input pins."
setup_dict = dict(input_pins={gpio: dict(mode=mode, **kwargs)})
logger.info(f"Setting up input pin {gpio} in mode {mode} using "
f"setup dict {setup_dict}")
with self.lock:
# send the setup dictionary
pack = json.dumps(setup_dict) + '\n'
self.serial.write(pack.encode())
self.receive_dict[gpio] = 0
def setup_output_pin(self, gpio: str, mode: str, **kwargs) -> None:
"""
:param gpio: the gpio pin to set up
:param mode: the mode of the pin
:param kwargs: additional arguments for the mode
"""
assert mode in ('OUTPUT', 'PWM'), \
f"Mode {mode} not supported for output pins on Pico"
setup_dict = dict(output_pins={gpio: dict(mode=mode, **kwargs)})
logger.info(f"Setting up output pin {gpio} in mode {mode} using "
f"setup dict {setup_dict}")
with self.lock:
# send the setup dictionary
pack = json.dumps(setup_dict) + '\n'
self.serial.write(pack.encode())
self.send_dict[gpio] = 0 if mode == 'OUTPUT' else kwargs['duty']
def remove_pin(self, gpio: str) -> None:
"""
:param gpio: the gpio pin to remove
"""
setup_dict = dict()
logger.info(f"Removing pin {gpio}")
if gpio in self.receive_dict:
setup_dict['input_pins'] = {gpio: {}}
del self.receive_dict[gpio]
elif gpio in self.send_dict:
setup_dict['output_pins'] = {gpio: {}}
del self.send_dict[gpio]
else:
logger.warning(f"Pin {gpio} not in send or receive dict.")
return
with self.lock:
# send the setup dictionary
pack = json.dumps(setup_dict) + '\n'
self.serial.write(pack.encode())
Circuitpython code and usage for the Pico
Circuitpython code to be installed on the Pico by copying files. Note, this has to be done only once. All pin creations and deletions will then be handled in software only. The boot.py file enables the second usb channel for communication. That channel will usually show up on the Pi under /dev/ttyACM1. The shell for accessing the REPL or the print command outputs can be accessed under /dev/ttyACM0.
As you can see, this was a simple session where only a single PWM pin was created and deleted. Deletion happens by calling stop() on the pin. Here is the output of another session where multiple pins were created and deleted. This is the output created by running the pins.py script multiple times with different arguments creating different pins.
The output above was created by initialising and running two digital input and output pins in the python interpreter (GP0 and GP1) and afterwards calling the pin.py script with different parameters like:
pins.py -w PICO.BCM.19 -tm 10
Viewing serial output for debugging
The easiest way to view the above output is by installing and running tio, a linux terminal program that can be attached to the serial port by tio /dev/ttyACM0. Note, that tio provides you with a terminal, which means you can type Ctrl-C to enter the REPL and work on the Pico interactively or press Ctrl-D to return to the above program. The corresponding familiar terminal code shown on the end of that last session.
Integration of Pi Pico into the Donkeycar pin ecosystem
Because PiGPIO is not supported on RPi 5 any longer we have to move the pin support for realtime pin tasks to the Pi Pico. This allows to migrate PWM input pins onto the Pico for using an RC controller to drive the car, as well as using the Pico's PWM pins to provide output pins for the servo and ESC. Other pins that required realtime signals like odometery or IR receivers can also be migrated to the Pico because we also support a PulseIn pin. In addition we aim to add support for the 4 analog pins to the pico, which for example could be used to monitor the battery voltage.
Changes in the
pins
moduleThe following new pins will be added:
The pico serial communicator
The following new class will be added. This is not a DonkeyCar part, but a low-level serial communication link between the above mentioned pins and the Pico board, connected to the RPi. It will send serial data as fast as possible back and forth between the Pico and the internal pin cache of the car, such that signals can be transferred at much higher rates than the car frequency. This class runs the serial connection in its own thread.
Circuitpython code and usage for the Pico
Circuitpython code to be installed on the Pico by copying files. Note, this has to be done only once. All pin creations and deletions will then be handled in software only. The
boot.py
file enables the second usb channel for communication. That channel will usually show up on the Pi under/dev/ttyACM1
. The shell for accessing the REPL or the print command outputs can be accessed under/dev/ttyACM0
.Running on the Pico
Here is a shell output from running above code on the Pico:
As you can see, this was a simple session where only a single PWM pin was created and deleted. Deletion happens by calling
stop()
on the pin. Here is the output of another session where multiple pins were created and deleted. This is the output created by running thepins.py
script multiple times with different arguments creating different pins.The output above was created by initialising and running two digital input and output pins in the python interpreter (GP0 and GP1) and afterwards calling the
pin.py
script with different parameters like:Viewing serial output for debugging
The easiest way to view the above output is by installing and running
tio
, a linux terminal program that can be attached to the serial port bytio /dev/ttyACM0
. Note, thattio
provides you with a terminal, which means you can typeCtrl-C
to enter the REPL and work on the Pico interactively or pressCtrl-D
to return to the above program. The corresponding familiar terminal code shown on the end of that last session.