Raspberry Pi RC Transmitter Phase 1 – Basic programming

Advertisements

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.

Leave a Reply