I am starting my Raspberry Pi based RC transmitter to allow me to use an Xbox controller to control my robots. Since the Raspberry Pi wont need to do anything other than work as an RC controller so doesn’t need to be powerful and needs to be as portable as possible I decided to use a Raspberry Pi Zero. A Raspberry Pi Zero, FrSky DHT DIY Transmitter Module, power supply and battery make for a 100mm x 50mm x 30mm box so can fit easily in a pocket. I am going to keep the battery on the outside to begin with as well as having an isolating switch on the transmitter module.
To start with I loaded the Raspberry Pi with Raspbian Stretch with Recommended Software. This allowed me to connect the Raspberry Pi to my TV and connect a keyboard and mouse to get started. Once the Raspberry Pi was setup I connected a WiFi USB dongle since the Raspberry Pi Zero I am using doesn’t come with WiFi built in. The WiFi dongle allows me to connect to the Raspberry Pi via SSH or VNC to program it. I have saved the WiFi details for both my home network and my phone’s hotspot so I can connect the Raspberry Pi while away from home. When being used as a RC transmitter I can leave the WiFi dongle disconnected.
I had already found some Python code on GitHub for a project called Flystick by Jsa that does what I want. But I want to learn how to do it myself so I decided to only use this code as a basic reference. I forbade myself from copying-and-pasting any of it. Any other code on the web was fair game to copy though.
To start programming I downloaded a copy of the PyGame Joystick demo and made sure I can read input values from my Xbox gamepad. The demo displays a window on the screen and shows the raw data from the connected joysticks. Since this is my first Python program I started slow and simply added a second column on the display that shows 8 channel values that are mapped to the axis and buttons on the gamepad. Since I prefer Mode 2 transmitters ch 1 and 2 are the right stick, ch 3 and 4 the left stick, ch 5 and 6 are the 2 triggers and ch 7 and 8 are mapped to X and Y buttons. Next was to convert the joystick -1 to 1 values to milli-second values that can be sent to the transmitter module.
Outputting a PPM signal messed me up for a few days. After reading Jsa’s Flystick code I noticed they were using the signal generator functions in the PIGPIO library. Since I wanted to learn as much as I could about how the code works I cut-and-pasted the demo code from a PIGPIO library website and it didn’t work. I was tempted to cut-and-paste the Flystick PPM code into my program but found some other code while Googling the problem. A user called Joan posted a PIGPIO PPM generator script on the Raspberry Pi forum in response to a user who wanted to do what I am trying to do so I cut-and-pasted it. The script had almost no comments so I spent a couple of hours going through the code line by line and commented everything to learn exactly how it works. I added a call to the script in my code and it worked after only a couple of bugs. One of the bugs was that the centering of all the channels was out by around 400uS so I had to adjust that in my code. I still haven’t worked out why this happened.
So now I have a very basic transmitter. The only way to adjust the centering, end points, expo or anything basic is to manually edit the code but it works.
Here is my code. Remember it is a modified version of the PyGame.Joystick example.
import pygame import signal import threading import pigpio import ppm ppm_output = [] ppm_output_pin = 18 pi = pigpio.pi() pi.set_mode(ppm_output_pin, pigpio.OUTPUT) # Define some colors BLACK = ( 0, 0, 0) WHITE = ( 255, 255, 255) if not pi.connected: exit(0) pi.wave_tx_stop() # Start with a clean slate. ppm = ppm.X(pi, ppm_output_pin, frame_ms=20) # This is a simple class that will help us print to the screen # It has nothing to do with the joysticks, just outputting the # information. class TextPrint: def __init__(self): self.reset() self.font = pygame.font.Font(None, 20) def print(self, screen, textString): textBitmap = self.font.render(textString, True, BLACK) screen.blit(textBitmap, [self.x, self.y]) self.y += self.line_height def reset(self): self.x = 10 self.y = 10 self.line_height = 15 def indent(self): self.x += 10 def unindent(self): self.x -= 10 def second_column(self): self.x = 510 self.y = 10 self.line_height = 15 pygame.init() # Set the width and height of the screen [width,height] size = [1000, 700] screen = pygame.display.set_mode(size) pygame.display.set_caption("RC Controller") #Loop until the user clicks the close button. done = False # Used to manage how fast the screen updates clock = pygame.time.Clock() # Initialize the joysticks pygame.joystick.init() # Get ready to print textPrint = TextPrint() # -------- Main Program Loop ----------- while done==False: ppm_joystick_values = [0, 0, 0, 0, 0, 0, 0, 0] ppm_chanels_us = [0, 0, 0, 0, 0, 0, 0, 0] # EVENT PROCESSING STEP for event in pygame.event.get(): # User did something if event.type == pygame.QUIT: # If user clicked close done=True # Flag that we are done so we exit this loop # Possible joystick actions: JOYAXISMOTION JOYBALLMOTION JOYBUTTONDOWN JOYBUTTONUP JOYHATMOTION if event.type == pygame.JOYBUTTONDOWN: print("Joystick button pressed.") if event.type == pygame.JOYBUTTONUP: print("Joystick button released.") # DRAWING STEP # First, clear the screen to white. Don't put other drawing commands # above this, or they will be erased with this command. screen.fill(WHITE) textPrint.reset() # Get count of joysticks joystick_count = pygame.joystick.get_count() textPrint.print(screen, "Number of joysticks: {}".format(joystick_count) ) textPrint.indent() # For each joystick: for i in range(joystick_count): joystick = pygame.joystick.Joystick(i) joystick.init() textPrint.print(screen, "Joystick {}".format(i) ) textPrint.indent() # Get the name from the OS for the controller/joystick name = joystick.get_name() textPrint.print(screen, "Joystick name: {}".format(name) ) # Usually axis run in pairs, up/down for one, and left/right for # the other. axes = joystick.get_numaxes() textPrint.print(screen, "Number of axes: {}".format(axes) ) textPrint.indent() for i in range( axes ): axis = joystick.get_axis( i ) textPrint.print(screen, "Axis {} value: {:>6.3f}".format(i, axis) ) textPrint.unindent() ppm_joystick_values[0] = joystick.get_axis(3) ppm_joystick_values[1] = joystick.get_axis(4) ppm_joystick_values[2] = joystick.get_axis(1) ppm_joystick_values[3] = joystick.get_axis(0) ppm_joystick_values[4] = joystick.get_axis(5) ppm_joystick_values[5] = joystick.get_axis(2) buttons = joystick.get_numbuttons() textPrint.print(screen, "Number of buttons: {}".format(buttons) ) textPrint.indent() for i in range( buttons ): button = joystick.get_button( i ) textPrint.print(screen, "Button {:>2} value: {}".format(i,button) ) textPrint.unindent() ppm_joystick_values[6] = joystick.get_button(0) ppm_joystick_values[7] = joystick.get_button(2) # Hat switch. All or nothing for direction, not like joysticks. # Value comes back in an array. hats = joystick.get_numhats() textPrint.print(screen, "Number of hats: {}".format(hats) ) textPrint.indent() for i in range( hats ): hat = joystick.get_hat( i ) textPrint.print(screen, "Hat {} value: {}".format(i, str(hat)) ) textPrint.unindent() textPrint.unindent() textPrint.second_column() textPrint.print(screen, "PPM values") textPrint.indent() for i in range(8): textPrint.print(screen, "Channel {}: {}".format(i+1, ppm_joystick_values[i])) textPrint.unindent() textPrint.print(screen, "PPM chanels in uS") textPrint.indent() for i in range(8): ppm_chanels_us[i] = int(round(1100 + (500 * ppm_joystick_values[i]))) textPrint.print(screen, "Chanel {} in uS: {}".format(i+1, ppm_chanels_us[i])) textPrint.unindent() ppm.update_channels(ppm_chanels_us) # ALL CODE TO DRAW SHOULD GO ABOVE THIS COMMENT # Go ahead and update the screen with what we've drawn. pygame.display.flip() # Limit to 20 frames per second clock.tick(20) # Close the window and quit. # If you forget this line, the program will 'hang' # on exit if running from IDLE. pygame.quit ()
PPM script with my comments
#!/usr/bin/env python #run as python script when file is made executable # PPM.py # 2016-02-18 # Public Domain import time #import time functions for waiting import pigpio #import GPIO functions class X: #create a class called X. when X is called __init__ is run GAP=100 #GAP is the time between pulses in uS. WAVES=3 #not sure what WAVES is yet def __init__(self, pi, gpio, channels=8, frame_ms=27): #function called when class X is used. variables passed to class are pi, gpio (gpio=18), channels and frame_ms self.pi = pi self.gpio = gpio if frame_ms < 5: #ensure the min and max frame widths are between 5 and 100mS. frame_ms = 5 channels = 2 #if frame width is less than 5 mS then there can only be 2 channels. does not take into account frame widths for any other number of channels elif frame_ms > 100: frame_ms = 100 self.frame_ms = frame_ms self._frame_us = int(frame_ms * 1000) #convert frame width for mS to uS self._frame_secs = frame_ms / 1000.0 #convert frame width to Seconds for some reason if channels < 1: #there must be at least 1 channel channels = 1 elif channels > (frame_ms // 2): #here we deal with the number of channels in relation to frame width. surely we only needed to specifiy the number of channels and calculate frame width from there? channels = int(frame_ms // 2) self.channels = channels self._widths = [1000] * channels #" set each channel to minimum pulse width" create a fglobal list of channel widths in uS defaulting to minimum width of 1000uS self._wid = [None]*self.WAVES #still not sure what WAVES is but there is now a global list called wid 3 values of [None] self._next_wid = 0 #and a global variable called next_wid set to 0 pi.write(gpio, pigpio.LOW) #set the gpio pin low self._update_time = time.time() #start the timer def _update(self): #another fuction within class X called update. the leading _ is to differentiate it from a system function wf =[] #not sure what wf stands for but it is the chain of output pulses to be sent to the gpio thingy micros = 0 #pulse chain in micro-seconds initailised at 0 for i in self._widths: #run through the list of channel widths wf.append(pigpio.pulse(0, 1<<self.gpio, self.GAP)) #turn the gpio pin off for GAP uS. this is 100uS wf.append(pigpio.pulse(1<<self.gpio, 0, i)) #turn the gpio pin on for channel width uS micros += (i+self.GAP) #micros = time taken for all of channels plus GAPs # off for the remaining frame period wf.append(pigpio.pulse(0, 1<<self.gpio, self._frame_us-micros)) #gpio low for the remainder of the time of frame width. if all channels at the minimum then this adds padding to maintain constant frame width self.pi.wave_add_generic(wf) wid = self.pi.wave_create() #output the frame on the gpio pin self.pi.wave_send_using_mode(wid, pigpio.WAVE_MODE_REPEAT_SYNC) self._wid[self._next_wid] = wid #_wid is a list of 3 frames. self._next_wid += 1 if self._next_wid >= self.WAVES: self._next_wid = 0 remaining = self._update_time + self._frame_secs - time.time() #this is where the frame width in seconds is used. pauses the function while the frame is being outputed if remaining > 0: time.sleep(remaining) self._update_time = time.time() wid = self._wid[self._next_wid] #delete all the values in _wid. the values only existed while the function was paused if wid is not None: #used in WAVE_MODE_REPEAT_SYNC to prevent errors self.pi.wave_delete(wid) self._wid[self._next_wid] = None def update_channel(self, channel, width): #function to load a channel width into the global variable self._widths[channel] = width self._update() def update_channels(self, widths): #load all the channel widths at once self._widths[0:len(widths)] = widths[0:self.channels] self._update() def cancel(self): #turn off the output self.pi.wave_tx_stop() for i in self._wid: if i is not None: self.pi.wave_delete(i) if __name__ == "__main__": #this is example code that is run as a stand alone script and ignored if imported by another program import time import PPM import pigpio pi = pigpio.pi() if not pi.connected: exit(0) pi.wave_tx_stop() # Start with a clean slate. ppm = PPM.X(pi, 6, frame_ms=20) #gpio = 6, channels = 8, frame_ms = 20 updates = 0 #step each channel through 200 steps and output each frame as it is done start = time.time() for chan in range(8): for pw in range(1000, 2000, 5): ppm.update_channel(chan, pw) updates += 1 end = time.time() secs = end - start print("{} updates in {:.1f} seconds ({}/s)".format(updates, secs, int(updates/secs))) ppm.update_channels([1000, 2000, 1000, 2000, 1000, 2000, 1000, 2000]) #set values for each channel and output the frame time.sleep(2) ppm.cancel() pi.stop()
The Next step is to make the transmitter a little more useful by adding adjustments for channel centering, end points, reversing and expo. Read it here.