Repository: autorope/donkeycar (language: python)

Issue:
Refactor motor drivers
Our motor drivers are pretty tightly bound to the libraries used to control PWM and GPIO pins.  This makes it hard to re-use code when building a new driver.

I'm in the process of enabling donkeycar software on a Duckiebot robot homebrew clone [here](https://github.com/Ezward/diyduckie).  It uses an TB6612FNG motor controller which is pin compatible with an L298N.  However, due to the evolution of the Duckiebot platform, one of it's motors get's it's enable signals from GPIO and the other from a PCA9685.  The other motor get's it's enable signals from GPIO and PWM from PCA9685.  

This would all be actually pretty easy to handle if we had our motor drivers accept a more generic interface for setting GPIO and PWM signals.  We could implement versions for the RPi.GPIO library, PiGPIO library and the PCA9685.  We could even write a version that controls this via an arduino using I2c or serial. 

- Create abstract base classes for  LogicPin and PwmPin.  
- Create concrete implementations for RPi.GPIO (and so Jetson.GPIO),  PCA9685 and PiGPIO
- Refactor motor drivers to take instances of LogicPin and PwmPin rather than raw pin numbers.
- update templates to instantiate instances of LogicPin and PwmPin when constructing drivetrain parts.

I would also refactor the templates by extractng the drivetrain construction from complete.py and making this a function that can be called by any template.  Then we could give other templates the flexibility to choose drivetrains, even if we only add a default in their config.  

Add more documentation on how to wire the motor drivers.  Each of our choices for motor implies a certain kind of hardware and wiring.  It would be useful to add a little more documentation and references on this.  Links to product descriptions on Amazon and Aliexpress tend to go stale very quickly, so we should focus on finding datasheets and tutorials that are likely to stay live longer.

This article does a very detailed job of describing the possible connections to an L298N https://www.etechnophiles.com/l298n-motor-driver-pin-diagram/  However, it does not explicitly describe how only the duty cycle inputs are used and the enable pins are not used.  The links to the product pages are 404ing.  I'm assuming this driver is for a 'mini' L298N.  That board uses just 4 wired to hookup 2 motors as this driver assumes.  Here is an article on how t use this with an arduino; https://www.instructables.com/Tutorial-for-Dual-Channel-DC-Motor-Driver-Board-PW/   Basically, the input pins are also used as PWM/duty cycle pins.  I assume you can use a full L298N module in this mode by leaving the enable pins jumpered and sending PWM via the input pint.


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index e2ce394ea..954ed0a78 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ community contributions.
 * Compete in self driving races like [DIY Robocars](http://diyrobocars.com)
 * Experiment with autopilots, mapping computer vision and neural networks.
 * Log sensor data. (images, user inputs, sensor readings)
-* Drive your car via a web or game controller.
+* Drive your car via a web or game controller or RC controller.
 * Leverage community contributed driving data.
 * Use existing CAD models for design upgrades.
 
diff --git a/donkeycar/management/base.py b/donkeycar/management/base.py
index 68759869d..95c0deb9f 100644
--- a/donkeycar/management/base.py
+++ b/donkeycar/management/base.py
@@ -4,7 +4,6 @@
 import socket
 import stat
 import sys
-from socket import *
 import logging
 
 from progress.bar import IncrementalBar
@@ -12,7 +11,7 @@
 from donkeycar.management.joystick_creator import CreateJoystick
 from donkeycar.management.tub import TubManager
 from donkeycar.pipeline.types import TubDataset
-from donkeycar.utils import *
+from donkeycar.utils import normalize_image, load_image, math
 
 PACKAGE_PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
 TEMPLATES_PATH = os.path.join(PACKAGE_PATH, 'templates')
@@ -54,7 +53,7 @@ class BaseCommand(object):
 
 
 class CreateCar(BaseCommand):
-    
+
     def parse_args(self, args):
         parser = argparse.ArgumentParser(prog='createcar', usage='%(prog)s [options]')
         parser.add_argument('--path', default=None, help='path where to create car folder')
@@ -62,11 +61,11 @@ def parse_args(self, args):
         parser.add_argument('--overwrite', action='store_true', help='should replace existing files')
         parsed_args = parser.parse_args(args)
         return parsed_args
-        
+
     def run(self, args):
         args = self.parse_args(args)
         self.create_car(path=args.path, template=args.template, overwrite=args.overwrite)
-  
+
     def create_car(self, path, template='complete', overwrite=False):
         """
         This script sets up the folder structure for donkey to work.
@@ -77,15 +76,15 @@ def create_car(self, path, template='complete', overwrite=False):
         # these are neeeded incase None is passed as path
         path = path or '~/mycar'
         template = template or 'complete'
-        print("Creating car folder: {}".format(path))
+        print(f"Creating car folder: {path}")
         path = make_dir(path)
-        
+
         print("Creating data & model folders.")
         folders = ['models', 'data', 'logs']
-        folder_paths = [os.path.join(path, f) for f in folders]   
+        folder_paths = [os.path.join(path, f) for f in folders]
         for fp in folder_paths:
             make_dir(fp)
-            
+
         # add car application and config files if they don't exist
         app_template_path = os.path.join(TEMPLATES_PATH, template+'.py')
         config_template_path = os.path.join(TEMPLATES_PATH, 'cfg_' + template + '.py')
@@ -97,11 +96,11 @@ def create_car(self, path, template='complete', overwrite=False):
         mycar_config_path = os.path.join(path, 'myconfig.py')
         train_app_path = os.path.join(path, 'train.py')
         calibrate_app_path = os.path.join(path, 'calibrate.py')
-        
+
         if os.path.exists(car_app_path) and not overwrite:
             print('Car app already exists. Delete it and rerun createcar to replace.')
         else:
-            print("Copying car application template: {}".format(template))
+            print(f"Copying car application template: {template}")
             shutil.copyfile(app_template_path, car_app_path)
             os.chmod(car_app_path, stat.S_IRWXU)
 
@@ -136,11 +135,11 @@ def create_car(self, path, template='complete', overwrite=False):
             for line in cfg:
                 if "import os" in line:
                     copy = True
-                if copy: 
+                if copy:
                     mcfg.write("# " + line)
             cfg.close()
             mcfg.close()
- 
+
         print("Donkey setup complete.")
 
 
@@ -154,72 +153,106 @@ def parse_args(self, args):
         parser.add_argument('--template', default=None, help='name of car template to use')
         parsed_args = parser.parse_args(args)
         return parsed_args
-        
+
     def run(self, args):
         args = self.parse_args(args)
         cc = CreateCar()
         cc.create_car(path=".", overwrite=True, template=args.template)
-        
+
 
 class FindCar(BaseCommand):
     def parse_args(self, args):
-        pass        
+        pass
 
     def run(self, args):
         print('Looking up your computer IP address...')
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-        s.connect(("8.8.8.8",80))
-        ip = s.getsockname()[0] 
-        print('Your IP address: %s ' %s.getsockname()[0])
+        s.connect(("8.8.8.8", 80))
+        ip = s.getsockname()[0]
+        print('Your IP address: %s ' % s.getsockname()[0])
         s.close()
-        
+
         print("Finding your car's IP address...")
         cmd = "sudo nmap -sP " + ip + "/24 | awk '/^Nmap/{ip=$NF}/B8:27:EB/{print ip}'"
         cmdRPi4 = "sudo nmap -sP " + ip + "/24 | awk '/^Nmap/{ip=$NF}/DC:A6:32/{print ip}'"
-        print("Your car's ip address is:" )
+        print("Your car's ip address is:")
         os.system(cmd)
         os.system(cmdRPi4)
 
 
-class CalibrateCar(BaseCommand):    
-    
+class CalibrateCar(BaseCommand):
+
     def parse_args(self, args):
         parser = argparse.ArgumentParser(prog='calibrate', usage='%(prog)s [options]')
-        parser.add_argument('--channel', help="The channel you'd like to calibrate [0-15]")
-        parser.add_argument('--address', default='0x40', help="The i2c address you'd like to calibrate [default 0x40]")
-        parser.add_argument('--bus', default=None, help="The i2c bus you'd like to calibrate [default autodetect]")
+        parser.add_argument(
+            '--pwm-pin',
+            help="The PwmPin specifier of pin to calibrate, like 'RPI_GPIO.BOARD.33' or 'PCA9685.1:40.13'")
+        parser.add_argument('--channel', default=None, help="The PCA9685 channel you'd like to calibrate [0-15]")
+        parser.add_argument(
+            '--address',
+            default='0x40',
+            help="The i2c address of PCA9685 you'd like to calibrate [default 0x40]")
+        parser.add_argument(
+            '--bus',
+            default=None,
+            help="The i2c bus of PCA9685 you'd like to calibrate [default autodetect]")
         parser.add_argument('--pwmFreq', default=60, help="The frequency to use for the PWM")
-        parser.add_argument('--arduino', dest='arduino', action='store_true', help='Use arduino pin for PWM (calibrate pin=<channel>)')
+        parser.add_argument(
+            '--arduino',
+            dest='arduino',
+            action='store_true',
+            help='Use arduino pin for PWM (calibrate pin=<channel>)')
         parser.set_defaults(arduino=False)
         parsed_args = parser.parse_args(args)
         return parsed_args
 
     def run(self, args):
         args = self.parse_args(args)
-        channel = int(args.channel)
 
-        if args.arduino == True:
+        if args.arduino:
             from donkeycar.parts.actuator import ArduinoFirmata
 
+            channel = int(args.channel)
             arduino_controller = ArduinoFirmata(servo_pin=channel)
-            print('init Arduino PWM on pin %d' %(channel))
+            print('init Arduino PWM on pin %d' % (channel))
             input_prompt = "Enter a PWM setting to test ('q' for quit) (0-180): "
+
+        elif args.pwm_pin is not None:
+            from donkeycar.parts.actuator import PulseController
+            from donkeycar.parts import pins
+
+            pwm_pin = None
+            try:
+                pwm_pin = pins.pwm_pin_by_id(args.pwm_pin)
+            except ValueError as e:
+                print(e)
+                print("See pins.py for a description of pin specification strings.")
+                exit(-1)
+            print(f'init pin {args.pwm_pin}')
+            freq = int(args.pwmFreq)
+            print(f"Using PWM freq: {freq}")
+            c = PulseController(pwm_pin)
+            input_prompt = "Enter a PWM setting to test ('q' for quit) (0-1500): "
+            print()
+
         else:
             from donkeycar.parts.actuator import PCA9685
             from donkeycar.parts.sombrero import Sombrero
 
-            s = Sombrero()
+            Sombrero()  # setup pins for Sombrero hat
 
+            channel = int(args.channel)
             busnum = None
             if args.bus:
                 busnum = int(args.bus)
             address = int(args.address, 16)
-            print('init PCA9685 on channel %d address %s bus %s' %(channel, str(hex(address)), str(busnum)))
+            print('init PCA9685 on channel %d address %s bus %s' % (channel, str(hex(address)), str(busnum)))
             freq = int(args.pwmFreq)
-            print("Using PWM freq: {}".format(freq))
+            print(f"Using PWM freq: {freq}")
             c = PCA9685(channel, address=address, busnum=busnum, frequency=freq)
             input_prompt = "Enter a PWM setting to test ('q' for quit) (0-1500): "
             print()
+
         while True:
             try:
                 val = input(input_prompt)
@@ -227,14 +260,14 @@ def run(self, args):
                     break
                 pmw = int(val)
                 if args.arduino == True:
-                    arduino_controller.set_pulse(channel,pmw)
+                    arduino_controller.set_pulse(channel, pmw)
                 else:
                     c.run(pmw)
             except KeyboardInterrupt:
                 print("\nKeyboardInterrupt received, exit.")
                 break
             except Exception as ex:
-                print("Oops, {}".format(ex))
+                print(f"Oops, {ex}")
 
 
 class MakeMovieShell(BaseCommand):
@@ -248,7 +281,10 @@ def __init__(self):
     def parse_args(self, args):
         parser = argparse.ArgumentParser(prog='makemovie')
         parser.add_argument('--tub', help='The tub to make movie from')
-        parser.add_argument('--out', default='tub_movie.mp4', help='The movie filename to create. default: tub_movie.mp4')
+        parser.add_argument(
+            '--out',
+            default='tub_movie.mp4',
+            help='The movie filename to create. default: tub_movie.mp4')
         parser.add_argument('--config', default='./config.py', help=HELP_CONFIG)
         parser.add_argument('--model', default=None, help='the model to use to show control outputs')
         parser.add_argument('--type', default=None, required=False, help='the model type to load')
@@ -256,7 +292,10 @@ def parse_args(self, args):
         parser.add_argument('--start', type=int, default=0, help='first frame to process')
         parser.add_argument('--end', type=int, default=-1, help='last frame to process')
         parser.add_argument('--scale', type=int, default=2, help='make image frame output larger by X mult')
-        parser.add_argument('--draw-user-input', default=True, action='store_false', help='show user input on the video')
+        parser.add_argument(
+            '--draw-user-input',
+            default=True, action='store_false',
+            help='show user input on the video')
         parsed_args = parser.parse_args(args)
         return parsed_args, parser
 
@@ -349,7 +388,7 @@ def get_activations(self, image_path, model_path, cfg):
 
         conv_layer_names = self.get_conv_layers(model)
         input_layer = model.get_layer(name='img_in').input
-        activations = []      
+        activations = []
         for conv_layer_name in conv_layer_names:
             output_layer = model.get_layer(name=conv_layer_name).output
 
@@ -363,9 +402,9 @@ def create_figure(self, activations):
 
         for i, layer in enumerate(activations):
             fig = self.plt.figure()
-            fig.suptitle('Layer {}'.format(i+1))
+            fig.suptitle(f'Layer {i+1}')
 
-            print('layer {} shape: {}'.format(i+1, layer.shape))
+            print(f'layer {i+1} shape: {layer.shape}')
             feature_maps = layer.shape[2]
             rows = math.ceil(feature_maps / cols)
 
@@ -373,7 +412,7 @@ def create_figure(self, activations):
                 self.plt.subplot(rows, cols, j + 1)
 
                 self.plt.imshow(layer[:, :, j])
-        
+
         self.plt.show()
 
     def get_conv_layers(self, model):
@@ -388,7 +427,7 @@ def parse_args(self, args):
         parser.add_argument('--image', help='path to image')
         parser.add_argument('--model', default=None, help='path to model')
         parser.add_argument('--config', default='./config.py', help=HELP_CONFIG)
-        
+
         parsed_args = parser.parse_args(args)
         return parsed_args
 
@@ -419,7 +458,7 @@ def plot_predictions(self, cfg, tub_paths, model_path, limit, model_type):
         user_angles = []
         user_throttles = []
         pilot_angles = []
-        pilot_throttles = []       
+        pilot_throttles = []
 
         base_path = Path(os.path.expanduser(tub_paths)).absolute().as_posix()
         dataset = TubDataset(config=cfg, tub_paths=[base_path],
@@ -566,7 +605,7 @@ def execute_from_command_line():
         'models': ModelDatabase,
         'ui': Gui,
     }
-    
+
     args = sys.argv[:]
 
     if len(args) > 1 and args[1] in commands.keys():
@@ -577,6 +616,6 @@ def execute_from_command_line():
         dk.utils.eprint('Usage: The available commands are:')
         dk.utils.eprint(list(commands.keys()))
 
-    
+
 if __name__ == "__main__":
     execute_from_command_line()
diff --git a/donkeycar/parts/actuator.py b/donkeycar/parts/actuator.py
index 08ef91616..9acd64795 100644
--- a/donkeycar/parts/actuator.py
+++ b/donkeycar/parts/actuator.py
@@ -4,11 +4,115 @@
 are wrapped in a mixer class before being used in the drive loop.
 """
 
+from abc import ABC, abstractmethod
 import time
+import logging
+from typing import Tuple
 
 import donkeycar as dk
+from donkeycar.parts.pins import OutputPin, PwmPin, PinState
+from donkeycar.utilities.deprecated import deprecated
 
-        
+logger = logging.getLogger(__name__)
+
+
+#
+# pwm/duty-cycle/pulse
+# - Standard RC servo pulses range from 1 millisecond (full reverse) 
+#   to 2 milliseconds (full forward) with 1.5 milliseconds being neutral (stopped).
+# - These pulses are typically send at 50 hertz (every 20 milliseconds).
+# - This means that, using the standard 50hz frequency, a 1 ms pulse 
+#   represents a 5% duty cycle and a 2 ms pulse represents a 10% duty cycle.
+# - The important part is the length of the pulse; 
+#   it must be in the range of 1 ms to 2ms.  
+# - So this means that if a different frequency is used, then the duty cycle 
+#   must be adjusted in order to get the 1ms to 2ms pulse.
+# - For instance, if a 60hz frequency is used, then a 1 ms pulse requires
+#   a duty cycle of 0.05 * 60 / 50 = 0.06 (6%) duty cycle
+# - We default the frequency of our PCA9685 to 60 hz, so pulses in 
+#   config are generally based on 60hz frequency and 12 bit values.
+#   We use 12 bit values because the PCA9685 has 12 bit resolution.
+#   So a 1 ms pulse is 0.06 * 4096 ~= 246, a neutral pulse of 0.09 duty cycle
+#   is 0.09 * 4096 ~= 367 and full forward pulse of 0.12 duty cycles
+#   is 0.12 * 4096 ~= 492
+# - These are generalizations that are useful for understanding the underlying
+#   api call arguments.  The final choice of duty-cycle/pulse length depends 
+#   on your hardware and perhaps your strategy (you may not want to go too fast, 
+#   and so you may choose is low max throttle pwm)
+#
+
+def duty_cycle(pulse_ms:float, frequency_hz:float) -> float:
+    """
+    Calculate the duty cycle, 0 to 1, of a pulse given
+    the frequency and the pulse length
+
+    :param pulse_ms:float the desired pulse length in milliseconds
+    :param frequency_hz:float the pwm frequency in hertz
+    :return:float duty cycle in range 0 to 1
+    """
+    ms_per_cycle = 1000 / frequency_hz
+    duty = pulse_ms / ms_per_cycle
+    return duty
+
+
+def pulse_ms(pulse_bits:int) -> float:
+    """
+    Calculate pulse width in milliseconds given a 
+    12bit pulse (as a PCA9685 would use).
+    Donkeycar throttle and steering PWM values are
+    based on PCA9685 12bit pulse values, where
+    0 is zero duty cycle and 4095 is 100% duty cycle.
+
+    :param pulse_bits:int 12bit integer in range 0 to 4095
+    :return:float pulse length in milliseconds
+    """
+    if pulse_bits < 0 or pulse_bits > 4095:
+        raise ValueError("pulse_bits must be in range 0 to 4095 (12bit integer)")
+    return pulse_bits / 4095
+
+
+class PulseController:
+    """
+    Controller that provides a servo PWM pulse using the given PwmPin
+    See pins.py for pin provider implementations.
+    """
+
+    def __init__(self, pwm_pin:PwmPin, pwm_scale:float = 1.0, pwm_inverted:bool = False) -> None:
+        """
+        :param pwm_pin:PwnPin pin that will emit the pulse.
+        :param pwm_scale:float scaling the 12 bit pulse value to compensate
+                        for non-standard pwm frequencies.
+        :param pwm_inverted:bool True to invert the duty cycle
+        """
+        self.pwm_pin = pwm_pin
+        self.scale = pwm_scale
+        self.inverted = pwm_inverted
+        self.started = pwm_pin.state() != PinState.NOT_STARTED
+
+    def set_pulse(self, pulse:int) -> None:
+        """
+        Set the length of the pulse using a 12 bit integer (0..4095)
+        :param pulse:int 12bit integer (0..4095)
+        """
+        if pulse < 0 or pulse > 4095:
+            raise ValueError("pulse must be in range 0 to 4095")
+
+        if not self.started:
+            self.pwm_pin.start()
+            self.started = True
+        if self.inverted:
+            pulse = 4095 - pulse
+        self.pwm_pin.duty_cycle(int(pulse * self.scale) / 4095)
+
+    def run(self, pulse:int) -> None:
+        """
+        Set the length of the pulse using a 12 bit integer (0..4095)
+        :param pulse:int 12bit integer (0..4095)
+        """
+        self.set_pulse(pulse)
+
+
+@deprecated("Deprecated in favor or PulseController.  This will be removed in a future release")
 class PCA9685:
     ''' 
     PWM motor controler using PCA9685 boards. 
@@ -32,6 +136,27 @@ def get_bus():
         self.channel = channel
         time.sleep(init_delay) # "Tamiya TBLE-02" makes a little leap otherwise
 
+    def set_high(self):
+        self.pwm.set_pwm(self.channel, 4096, 0)
+
+    def set_low(self):
+        self.pwm.set_pwm(self.channel, 0, 4096)
+
+    def set_duty_cycle(self, duty_cycle):
+        if duty_cycle < 0 or duty_cycle > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        if duty_cycle == 1:
+            self.set_high()
+        elif duty_cycle == 0:
+            self.set_low()
+        else:
+            # duty cycle is fraction of the 12 bits
+            pulse = int(4096 * duty_cycle)
+            try:
+                self.pwm.set_pwm(self.channel, 0, pulse)
+            except:
+                self.pwm.set_pwm(self.channel, 0, pulse)
+
     def set_pulse(self, pulse):
         try:
             self.pwm.set_pwm(self.channel, 0, int(pulse * self.pwm_scale))
@@ -42,6 +167,7 @@ def run(self, pulse):
         self.set_pulse(pulse)
 
 
+@deprecated("Deprecated in favor or PulseController.  This will be removed in a future release")
 class PiGPIO_PWM():
     '''
     # Use the pigpio python module and daemon to get hardware pwm controls from
@@ -57,6 +183,8 @@ class PiGPIO_PWM():
     #
     # If you use a control circuit that inverts the steering signal, set inverted to True
     # Default multipler for pulses from config etc is 100
+    #
+    #   
     '''
 
     def __init__(self, pin, pgio=None, freq=75, inverted=False):
@@ -81,17 +209,21 @@ def set_pulse(self, pulse):
     def run(self, pulse):
         self.set_pulse(pulse)
 
+
 class PWMSteering:
     """
-    Wrapper over a PWM motor controller to convert angles to PWM pulses.
+    Wrapper over a PWM pulse controller to convert angles to PWM pulses.
     """
     LEFT_ANGLE = -1
     RIGHT_ANGLE = 1
 
-    def __init__(self,
-                 controller=None,
-                 left_pulse=290,
-                 right_pulse=490):
+    def __init__(self, controller, left_pulse, right_pulse):
+
+        if controller is None:
+            raise ValueError("PWMSteering requires a set_pulse controller to be passed")
+        set_pulse = getattr(controller, "set_pulse", None)
+        if set_pulse is None or not callable(set_pulse):
+            raise ValueError("controller must have a set_pulse method")
 
         self.controller = controller
         self.left_pulse = left_pulse
@@ -99,7 +231,7 @@ def __init__(self,
         self.pulse = dk.utils.map_range(0, self.LEFT_ANGLE, self.RIGHT_ANGLE,
                                         self.left_pulse, self.right_pulse)
         self.running = True
-        print('PWM Steering created')
+        logger.info('PWM Steering created')
 
     def update(self):
         while self.running:
@@ -124,17 +256,19 @@ def shutdown(self):
 
 class PWMThrottle:
     """
-    Wrapper over a PWM motor controller to convert -1 to 1 throttle
+    Wrapper over a PWM pulse controller to convert -1 to 1 throttle
     values to PWM pulses.
     """
     MIN_THROTTLE = -1
     MAX_THROTTLE = 1
 
-    def __init__(self,
-                 controller=None,
-                 max_pulse=300,
-                 min_pulse=490,
-                 zero_pulse=350):
+    def __init__(self, controller, max_pulse, min_pulse, zero_pulse):
+
+        if controller is None:
+            raise ValueError("PWMThrottle requires a set_pulse controller to be passed")
+        set_pulse = getattr(controller, "set_pulse", None)
+        if set_pulse is None or not callable(set_pulse):
+            raise ValueError("controller must have a set_pulse method")
 
         self.controller = controller
         self.max_pulse = max_pulse
@@ -143,7 +277,7 @@ def __init__(self,
         self.pulse = zero_pulse
 
         # send zero pulse to calibrate ESC
-        print("Init ESC")
+        logger.info("Init ESC")
         self.controller.set_pulse(self.max_pulse)
         time.sleep(0.01)
         self.controller.set_pulse(self.min_pulse)
@@ -151,7 +285,7 @@ def __init__(self,
         self.controller.set_pulse(self.zero_pulse)
         time.sleep(1)
         self.running = True
-        print('PWM Throttle created')
+        logger.info('PWM Throttle created')
 
     def update(self):
         while self.running:
@@ -174,12 +308,24 @@ def shutdown(self):
         self.run(0)
         self.running = False
 
+
+#
+# This seems redundant.  If it's really emulating and PCA9685, then
+# why don't we just use that code?
+# - this is not used in any templates
+# - this is not documented in docs or elsewhere
+# - this seems redundant; if it emultates a PCA9685 then we should be able to use that code. 
+# - there is an intention to implement a Firmata driver in pins.py.  
+#   Teensy can run Firmata, so that is a way to get Teensy support. 
+#   See https://www.pjrc.com/teensy/td_libs_Firmata.html
+#
+@deprecated("JHat is unsupported/undocumented in the framework.  It will be removed in a future release.")
 class JHat:
     ''' 
-    PWM motor controler using Teensy emulating PCA9685. 
+    PWM motor controller using Teensy emulating PCA9685. 
     '''
     def __init__(self, channel, address=0x40, frequency=60, busnum=None):
-        print("Firing up the Hat")
+        logger.info("Firing up the Hat")
         import Adafruit_PCA9685
         LED0_OFF_L = 0x08
         # Initialise the PCA9685 using the default address (0x40).
@@ -207,6 +353,17 @@ def set_pwm(self, channel, on, off):
     def run(self, pulse):
         self.set_pulse(pulse)
 
+
+#
+# JHatReader is on the chopping block for removal
+# - it is not integrated into any templates
+# - it is not documented in docs or elsewhere
+# This appears to be a way to read RC receiving input.  As such
+# it would more appropriately be integrated into controllers.py.
+# If that can be addressed then we would keep this, 
+# otherwise this should be removed.
+#
+@deprecated("JHatReader is unsupported/undocumented in the framework.  It may be removed in a future release.")
 class JHatReader:
     ''' 
     Read RC controls from teensy 
@@ -231,7 +388,7 @@ def read_pwm(self):
         # first byte of header must be 100, otherwize we might be reading
         # in the wrong byte offset
         while h1 != 100:
-            print("skipping to start of header")
+            logger.debug("skipping to start of header")
             h1 = self.pwm._device.readU8(self.register)
         
         h2 = self.pwm._device.readU8(self.register)
@@ -261,9 +418,15 @@ def shutdown(self):
         time.sleep(0.1)
 
 
-
-
-
+#
+# Adafruit_DCMotor_Hat support is on the block for removal
+# - it is not integrated into any templates
+# - It is not documented in the docs or otherwise
+# A path forward would be to add a drive train option
+# DRIVE_TRAIN_TYPE = "DC_TWO_WHEEL_ADAFRUIT" 
+# and integrate this into complete.py
+#
+@deprecated("This appears to be unsupported/undocumented in the framework. This may be removed in a future release")
 class Adafruit_DCMotor_Hat:
     ''' 
     Adafruit DC Motor Controller 
@@ -308,10 +471,22 @@ def shutdown(self):
         self.mh.getMotor(self.motor_num).run(Adafruit_MotorHAT.RELEASE)
 
 
+#
+# Maestro Servo Controller support is on the block for removal
+# - it is not integrated into any templates
+# - It is not documented in the docs or otherwise
+# - It seems to require a separate AStar microcontroller for which there is no firmware included or referenced
+# If that can be addressed, then we can add Maestro support to the pin provide api in pins.py
+# so it can be used a source of TTL and PWM for the generalized motor drivers.
+# Perhaps the AStar controller is not integral to using this as a source of servo pulses in which
+# case it seems pretty straight forward to integrate this into pins.py and then delete this class.
+#
+@deprecated("This appears to be unsupported/undocumented in the framework. This may be removed in a future release")
 class Maestro:
     '''
     Pololu Maestro Servo controller
     Use the MaestroControlCenter to set the speed & acceleration values to 0!
+    See https://www.pololu.com/docs/0J40/all
     '''
     import threading
 
@@ -391,6 +566,17 @@ def readline(self):
         return ret
 
 
+#
+# Teensy support is on the chopping block.
+# - It is not integrated into any template
+# - It is not documented in the docs or otherwise
+# - It presumably requires a firmware to be uploaded to the teensy, but no firmware is referenced.
+# If that can be addressed, then we can add Teensy support to the pin provide api in pins.py
+# so it can be used a source of TTL and PWM for the generalized motor drivers.
+# Another route is to implement Firmata protocol (perhaps a version of the Arduino sketch,
+# see ArduinoFirmata below)
+#
+@deprecated("This appears to be unsupported/undocumented in the framework. This may be removed in a future release")
 class Teensy:
     '''
     Teensy Servo controller
@@ -480,6 +666,7 @@ def astar_readline(self):
 
         return ret
 
+
 class MockController(object):
     def __init__(self):
         pass
@@ -491,63 +678,93 @@ def shutdown(self):
         pass
 
 
-class L298N_HBridge_DC_Motor(object):
-    '''
-    Motor controlled with an L298N hbridge from the gpio pins on Rpi
-    '''
-    def __init__(self, pin_forward, pin_backward, pwm_pin, freq = 50):
-        import RPi.GPIO as GPIO
+class L298N_HBridge_3pin(object):
+    """
+    Motor controlled with an L298N hbridge, 
+    chosen with configuration DRIVETRAIN_TYPE=DC_TWO_WHEEL_L298N
+    Uses two OutputPins to select direction and 
+    a PwmPin to control the power to the motor.
+    See pins.py for pin provider implementations.
+
+    See https://www.electronicshub.org/raspberry-pi-l298n-interface-tutorial-control-dc-motor-l298n-raspberry-pi/
+    for a discussion of how the L298N hbridge module is wired in 3-pin mode.
+    This also applies to the some other driver chips that emulate
+    the L298N, such as the TB6612FNG motor driver.
+    """
+
+    def __init__(self, pin_forward:OutputPin, pin_backward:OutputPin, pwm_pin:PwmPin, zero_throttle:float=0, max_duty=0.9):
+        """
+        :param pin_forward:OutputPin when HIGH the motor will turn clockwise 
+                        using the output of the pwm_pin as a duty_cycle
+        :param pin_backward:OutputPin when HIGH the motor will turn counter-clockwise
+                            using the output of the pwm_pin as a duty_cycle
+        :param pwm_pin:PwmPin takes a duty cycle in the range of 0 to 1, 
+                    where 0 is fully off and 1 is fully on.
+        :param zero_throttle: values at or below zero_throttle are treated as zero.
+        :param max_duty: the maximum duty cycle that will be send to the motors
+
+        NOTE: if pin_forward and pin_backward are both LOW, then the motor is
+            'detached' and will glide to a stop.
+            if pin_forward and pin_backward are both HIGH, then the motor 
+            will be forcibly stopped (can be used for braking)
+        """
         self.pin_forward = pin_forward
         self.pin_backward = pin_backward
         self.pwm_pin = pwm_pin
-
-        GPIO.setmode(GPIO.BOARD)
-        GPIO.setup(self.pin_forward, GPIO.OUT)
-        GPIO.setup(self.pin_backward, GPIO.OUT)
-        GPIO.setup(self.pwm_pin, GPIO.OUT)
-        
-        self.pwm = GPIO.PWM(self.pwm_pin, freq)
-        self.pwm.start(0)
-
-    def run(self, speed):
-        import RPi.GPIO as GPIO
-        '''
-        Update the speed of the motor where 1 is full forward and
-        -1 is full backwards.
-        '''
-        if speed > 1 or speed < -1:
+        self.zero_throttle = zero_throttle
+        self.throttle = 0
+        self.max_duty = max_duty
+        self.pin_forward.start(PinState.LOW)
+        self.pin_backward.start(PinState.LOW)
+        self.pwm_pin.start(0)
+
+    def run(self, throttle:float) -> None:
+        """
+        Update the speed of the motor 
+        :param throttle:float throttle value in range -1 to 1,
+                        where 1 is full forward and -1 is full backwards.
+        """
+        if throttle > 1 or throttle < -1:
             raise ValueError( "Speed must be between 1(forward) and -1(reverse)")
         
-        self.speed = speed
-        max_duty = 90 #I've read 90 is a good max
-        self.throttle = int(dk.utils.map_range(speed, -1, 1, -max_duty, max_duty))
-        
-        if self.throttle > 0:
-            self.pwm.ChangeDutyCycle(self.throttle)
-            GPIO.output(self.pin_forward, GPIO.HIGH)
-            GPIO.output(self.pin_backward, GPIO.LOW)
-        elif self.throttle < 0:
-            self.pwm.ChangeDutyCycle(-self.throttle)
-            GPIO.output(self.pin_forward, GPIO.LOW)
-            GPIO.output(self.pin_backward, GPIO.HIGH)
+        self.speed = throttle
+        self.throttle = dk.utils.map_range_float(throttle, -1, 1, -self.max_duty, self.max_duty)
+        if self.throttle > self.zero_throttle:
+            self.pwm_pin.duty_cycle(self.throttle)
+            self.pin_backward.output(PinState.LOW)
+            self.pin_forward.output(PinState.HIGH)
+        elif self.throttle < -self.zero_throttle:
+            self.pwm_pin.duty_cycle(-self.throttle)
+            self.pin_forward.output(PinState.LOW)
+            self.pin_backward.output(PinState.HIGH)
         else:
-            self.pwm.ChangeDutyCycle(self.throttle)
-            GPIO.output(self.pin_forward, GPIO.LOW)
-            GPIO.output(self.pin_backward, GPIO.LOW)
-
+            self.pwm_pin.duty_cycle(0)
+            self.pin_forward.output(PinState.LOW)
+            self.pin_backward.output(PinState.LOW)
 
     def shutdown(self):
-        import RPi.GPIO as GPIO
-        self.pwm.stop()
-        GPIO.cleanup()
+        self.pwm_pin.stop()
+        self.pin_forward.stop()
+        self.pin_backward.stop()
 
 
 class TwoWheelSteeringThrottle(object):
+    """
+    Modify individual differential drive wheel throttles
+    in order to implemeht steering.
+    """
 
-    def run(self, throttle, steering):
+    def run(self, throttle:float, steering:float) -> Tuple[float, float]:
+        """
+        :param throttle:float throttle value in range -1 to 1,
+                        where 1 is full forward and -1 is full backwards.
+        :param steering:float steering value in range -1 to 1,
+                        where -1 is full left and 1 is full right.
+        :return: tuple of left motor and right motor throttle values in range -1 to 1
+                 where 1 is full forward and -1 is full backwards.
+        """
         if throttle > 1 or throttle < -1:
             raise ValueError( "throttle must be between 1(forward) and -1(reverse)")
- 
         if steering > 1 or steering < -1:
             raise ValueError( "steering must be between 1(right) and -1(left)")
 
@@ -561,71 +778,90 @@ def run(self, throttle, steering):
 
         return left_motor_speed, right_motor_speed
 
-    def shutdown(self):
+    def shutdown(self) -> None:
         pass
 
 
-class Mini_HBridge_DC_Motor_PWM(object):
-    '''
-    Motor controlled with an mini hbridge from the gpio pins on Rpi
-    This can be using the L298N as above, but wired differently with only
-    two inputs and no enable line.
-    https://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Dtoys-and-games&field-keywords=Mini+Dual+DC+Motor+H-Bridge+Driver
-    https://www.aliexpress.com/item/5-pc-2-DC-Motor-Drive-Module-Reversing-PWM-Speed-Dual-H-Bridge-Stepper-Motor-Mini
-    '''
-    def __init__(self, pin_forward, pin_backward, freq = 50, max_duty = 90):
-        '''
-        max_duy is from 0 to 100. I've read 90 is a good max.
-        '''
-        import RPi.GPIO as GPIO
+class L298N_HBridge_2pin(object):
+    """
+    Motor controlled with an 'mini' L298N hbridge using 2 PwmPins,
+    one for forward pwm and for reverse pwm.
+    Chosen with configuration DRIVETRAIN_TYPE=DC_TWO_WHEEL
+    See pins.py for pin provider implementations.
+
+    See https://www.instructables.com/Tutorial-for-Dual-Channel-DC-Motor-Driver-Board-PW/
+    for how an L298N mini-hbridge modules is wired.  
+    This driver can also be used for an L9110S/HG7881 motor driver.  See 
+    https://electropeak.com/learn/interfacing-l9110s-dual-channel-h-bridge-motor-driver-module-with-arduino/
+    for how an L9110S motor driver module is wired.
+    """
+
+    def __init__(self, pin_forward:PwmPin, pin_backward:PwmPin, zero_throttle:float=0, max_duty = 0.9):
+        """
+        pin_forward:PwmPin Takes a duty cycle in the range of 0 to 1, 
+                        where 0 is fully off and 1 is fully on.
+                        When the duty_cycle > 0 the motor will turn clockwise 
+                        proportial to the duty_cycle
+        pin_backward:PwmPin Takes a duty cycle in the range of 0 to 1, 
+                            where 0 is fully off and 1 is fully on.
+                            When the duty_cycle > 0 the motor will turn counter-clockwise 
+                            proportial to the duty_cycle
+        zero_throttle: values at or below zero_throttle are treated as zero.
+        max_duty: the maximum duty cycle that will be send to the motors
+
+        NOTE: if pin_forward and pin_backward are both at duty_cycle == 0, 
+            then the motor is 'detached' and will glide to a stop.
+            if pin_forward and pin_backward are both at duty_cycle == 1, 
+            then the motor will be forcibly stopped (can be used for braking)
+        max_duty is from 0 to 1 (fully off to fully on). I've read 0.9 is a good max.
+        """
         self.pin_forward = pin_forward
         self.pin_backward = pin_backward
+        self.zero_throttle = zero_throttle
         self.max_duty = max_duty
-        
-        GPIO.setmode(GPIO.BOARD)
-        GPIO.setup(self.pin_forward, GPIO.OUT)
-        GPIO.setup(self.pin_backward, GPIO.OUT)
-        
-        self.pwm_f = GPIO.PWM(self.pin_forward, freq)
-        self.pwm_f.start(0)
-        self.pwm_b = GPIO.PWM(self.pin_backward, freq)
-        self.pwm_b.start(0)
 
-    def run(self, speed):
-        import RPi.GPIO as GPIO
-        '''
-        Update the speed of the motor where 1 is full forward and
-        -1 is full backwards.
-        '''
-        if speed is None:
+        self.throttle=0
+        self.speed=0
+        
+        self.pin_forward.start(0)
+        self.pin_backward.start(0)
+
+    def run(self, throttle:float) -> None:
+        """
+        Update the speed of the motor
+        :param throttle:float throttle value in range -1 to 1,
+                        where 1 is full forward and -1 is full backwards.
+        """
+        if throttle is None:
             return
         
-        if speed > 1 or speed < -1:
-            raise ValueError( "Speed must be between 1(forward) and -1(reverse)")
+        if throttle > 1 or throttle < -1:
+            raise ValueError( "Throttle must be between 1(forward) and -1(reverse)")
         
-        self.speed = speed
-        self.throttle = int(dk.utils.map_range(speed, -1, 1, -self.max_duty, self.max_duty))
+        self.speed = throttle
+        self.throttle = dk.utils.map_range_float(throttle, -1, 1, -self.max_duty, self.max_duty)
         
-        if self.throttle > 0:
-            self.pwm_f.ChangeDutyCycle(self.throttle)
-            self.pwm_b.ChangeDutyCycle(0)
-        elif self.throttle < 0:
-            self.pwm_f.ChangeDutyCycle(0)
-            self.pwm_b.ChangeDutyCycle(-self.throttle)
+        if self.throttle > self.zero_throttle:
+            self.pin_backward.duty_cycle(0)
+            self.pin_forward.duty_cycle(self.throttle)
+        elif self.throttle < -self.zero_throttle:
+            self.pin_forward.duty_cycle(0)
+            self.pin_backward.duty_cycle(-self.throttle)
         else:
-            self.pwm_f.ChangeDutyCycle(0)
-            self.pwm_b.ChangeDutyCycle(0)
-
+            self.pin_forward.duty_cycle(0)
+            self.pin_backward.duty_cycle(0)
 
     def shutdown(self):
-        import RPi.GPIO as GPIO
-        self.pwm_f.ChangeDutyCycle(0)
-        self.pwm_b.ChangeDutyCycle(0)
-        self.pwm_f.stop()
-        self.pwm_b.stop()
-        GPIO.cleanup()
+        self.pin_forward.stop()
+        self.pin_backward.stop()
 
     
+#
+# This is being replaced by pins.py and PulseController.  
+# GPIO pins can be configured using RPi.GPIO or PIGPIO, 
+# so this is redundant
+#
+@deprecated("This will be removed in a future release in favor of PulseController")
 class RPi_GPIO_Servo(object):
     '''
     Servo controlled from the gpio pins on Rpi
@@ -649,7 +885,7 @@ def run(self, pulse):
         '''
         #I've read 90 is a good max
         self.throttle = dk.map_frange(pulse, -1.0, 1.0, self.min, self.max)
-        #print(pulse, self.throttle)
+        #logger.debug(pulse, self.throttle)
         self.pwm.ChangeDutyCycle(self.throttle)
 
 
@@ -659,6 +895,11 @@ def shutdown(self):
         GPIO.cleanup()
 
 
+#
+# This is being replaced by pins.py.  GPIO pins can be 
+# configured using RPi.GPIO or PIGPIO, so ServoBlaster is redundant
+#
+@deprecated("This will be removed in a future release in favor of PulseController")
 class ServoBlaster(object):
     '''
     Servo controlled from the gpio pins on Rpi
@@ -697,7 +938,27 @@ def shutdown(self):
         self.run((self.max + self.min) / 2)
         self.servoblaster.close()
 
-
+#
+# TODO: integrate ArduinoFirmata support into pin providers, then we can remove all of this code and use PulseController
+#
+# Arduino/Microcontroller PWM support. 
+# Firmata is a specification for configuring general purpose microcontrollers remotey.
+# The implementatino for Arduino is used here.
+#
+# See https://docs.donkeycar.com/parts/actuators/#arduino for how to set this up.
+# Firmata Protocol https://github.com/firmata/protocol
+# Arduino implementation https://github.com/firmata/arduino
+#
+# NOTE: to create a general purpose InputPin/OutputPin/PwmPin with support for servo pulses between 1ms and 2ms
+#       with good resolution, it is likely we will need to create our own sketch based on the Arduino Firmata
+#       examples.  By default, analog write is not adequate to support servos; it is good for motor duty cycles
+#       but is poor for servos because of the low resolution and poor control of frequency.  So we need to
+#       use the Servo.h library and dynamically add a Servo instance to a pin when it is configured for
+#       analog output.  Further, we should use writeMicroseconds for output and so interpret values
+#       to the pin as microseconds for the on part of the pulse.  See the various flavors of examples
+#       in the Arduino Firmata repo linked above.
+#
+@deprecated("This will be removed in a future release and Arduino support will be added to pins.py")
 class ArduinoFirmata:
     '''
     PWM controller using Arduino board.
@@ -728,7 +989,7 @@ def set_esc_pulse(self, angle):
         self.set_pulse(self.esc_pin, int(angle))
 
 
-
+@deprecated("This will be removed in a future release and Arduino PWM support will be add to pins.py")
 class ArdPWMSteering:
     """
     Wrapper over a Arduino Firmata controller to convert angles to PWM pulses.
@@ -747,7 +1008,7 @@ def __init__(self,
         self.pulse = dk.utils.map_range(0, self.LEFT_ANGLE, self.RIGHT_ANGLE,
                                         self.left_pulse, self.right_pulse)
         self.running = True
-        print('Arduino PWM Steering created')
+        logger.info('Arduino PWM Steering created')
 
     def run(self, angle):
         # map absolute angle to angle that vehicle can implement.
@@ -764,6 +1025,7 @@ def shutdown(self):
         self.running = False
 
 
+@deprecated("This will be removed in a future release and Arduino PWM support will be add to pins.py")
 class ArdPWMThrottle:
 
     """
@@ -786,7 +1048,7 @@ def __init__(self,
         self.pulse = zero_pulse
 
         # send zero pulse to calibrate ESC
-        print("Init ESC")
+        logger.info("Init ESC")
         self.controller.set_esc_pulse(self.max_pulse)
         time.sleep(0.01)
         self.controller.set_esc_pulse(self.min_pulse)
@@ -794,7 +1056,7 @@ def __init__(self,
         self.controller.set_esc_pulse(self.zero_pulse)
         time.sleep(1)
         self.running = True
-        print('Arduino PWM Throttle created')
+        logger.info('Arduino PWM Throttle created')
 
     def run(self, throttle):
         if throttle > 0:
diff --git a/donkeycar/parts/pins.py b/donkeycar/parts/pins.py
new file mode 100644
index 000000000..2a8631db8
--- /dev/null
+++ b/donkeycar/parts/pins.py
@@ -0,0 +1,1043 @@
+"""
+pins.py - high level ttl an pwm pin abstraction.
+This is designed to allow drivers that use ttl and pwm
+pins to be reusable with different underlying libaries
+and techologies.
+
+The abstract classes InputPin, OutputPin and PwmPin
+provide an interface for starting, using and cleaning up the pins.
+The factory functions input_pin_by_id(), output_pin_by_id()
+and pwm_pin_by_id() construct pins given a string id
+that specifies the underlying pin provider and it's attributes.
+There are implementations for the Rpi.GPIO library and
+for the PCA9685.
+
+Pin id allows pins to be selected using a single string to
+select from different underlying providers, numbering schemes and settings.
+
+Use Rpi.GPIO library, GPIO.BOARD pin numbering scheme, pin number 13
+ pin = input_pin_by_id("RPI_GPIO.BOARD.13")
+
+Use Rpi.GPIO library, GPIO.BCM broadcom pin numbering scheme, gpio pin number 33
+ pin = output_pin_by_id("RPI_GPIO.BCM.33")
+
+Use PCA9685 on bus 0 at address 0x40, channel 7
+ pin = pwm_pin_by_id("PCA9685.0:40.7")
+
+"""
+from abc import ABC, abstractmethod
+from typing import Any, Callable
+import logging
+
+
+logger = logging.getLogger(__name__)
+
+
+class PinState:
+    LOW: int = 0
+    HIGH: int = 1
+    NOT_STARTED: int = -1
+
+
+class PinEdge:
+    RISING: int = 1
+    FALLING: int = 2
+    BOTH: int = 3
+
+
+class PinPull:
+    PULL_NONE: int = 1
+    PULL_UP: int = 2
+    PULL_DOWN: int = 3
+
+
+class PinProvider:
+    RPI_GPIO = "RPI_GPIO"
+    PCA9685 = "PCA9685"
+    PIGPIO = "PIGPIO"
+
+
+class PinScheme:
+    BOARD = "BOARD"  # board numbering
+    BCM = "BCM"      # broadcom gpio numbering
+
+
+#
+# #### Base interface for input/output/pwm pins
+# #### Implementations derive from these abstact classes
+#
+
+class InputPin(ABC):
+
+    def __init__(self) -> None:
+        super().__init__()
+
+    @abstractmethod
+    def start(self, on_input=None, edge: int = PinEdge.RISING) -> None:
+        """
+        Start the pin in input mode.
+        :param on_input: function to call when an edge is detected, or None to ignore
+        :param edge: type of edge(s) that trigger on_input; default is PinEdge.RISING
+        This raises a RuntimeError if the pin is already started.
+        You can check to see if the pin is started by calling
+        state() and checking for PinState.NOT_STARTED
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def stop(self) -> None:
+        """
+        Stop the pin and return it to PinState.NOT_STARTED
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def state(self) -> int:
+        """
+        Return most recent input state.  This does not re-read the input pin,
+        it just returns that last value read by the input() method.
+        If the pin is not started or has been stopped,
+        this will return PinState:NOT_STARTED
+        """
+        return PinState.NOT_STARTED  # subclasses must override
+
+    @abstractmethod
+    def input(self) -> int:
+        """
+        Read the input state from the pin.
+        :return: PinState.LOW/HIGH or PinState.NOT_STARTED if pin not started
+        """
+        return PinState.NOT_STARTED  # subclasses must override
+
+
+class OutputPin(ABC):
+
+    def __init__(self) -> None:
+        super().__init__()
+
+    @abstractmethod
+    def start(self, state: int = PinState.LOW) -> None:
+        """
+        Start the pin in output mode and with given starting state.
+        This raises and RuntimeError if the pin is already started.
+        You can check to see if the pin is started by calling
+        state() and checking for PinState.NOT_STARTED
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def stop(self) -> None:
+        """
+        Stop the pin and return it to PinState.NOT_STARTED
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def state(self) -> int:
+        """
+        Return most recent output state.  This does not re-read the pin,
+        It just returns that last value set by the output() method.
+        If the pin is not started or has been stopped,
+        this will return PinState:NOT_STARTED
+        :return: most recent output state OR PinState.NOT_STARTED if pin not started.
+        """
+        return PinState.NOT_STARTED  # subclasses must override
+
+    @abstractmethod
+    def output(self, state: int) -> None:
+        """
+        Set the output state of the pin to either
+        :param state: PinState.LOW or PinState.HIGH
+        :except: RuntimeError if pin not stated.
+        """
+        pass  # subclasses must override
+
+
+class PwmPin(ABC):
+
+    def __init__(self) -> None:
+        super().__init__()
+
+    @abstractmethod
+    def start(self, duty: float = 0) -> None:
+        """
+        Start the pin in output mode and with given starting state.
+        This raises and RuntimeError if the pin is already started.
+        You can check to see if the pin is started by calling
+        state() and checking for PinState.NOT_STARTED
+        :param duty: duty cycle in range 0 to 1
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def stop(self) -> None:
+        """
+        Stop the pin and return it to PinState.NOT_STARTED
+        """
+        pass  # subclasses should override this
+
+    @abstractmethod
+    def state(self) -> float:
+        """
+        Return most recent output state.  This does not re-read the pin,
+        It just returns that last value set by the output() method.
+        If the pin is not started or has been stopped,
+        this will return PinState:NOT_STARTED
+        :return: most recent output duty_cycle
+        """
+        return PinState.NOT_STARTED  # subclasses must override
+
+    @abstractmethod
+    def duty_cycle(self, duty: float) -> None:
+        """
+        Set the output duty cycle of the pin
+        in range 0 to 1.0 (0% to 100%)
+        :param duty: duty cycle in range 0 to 1
+        :except: RuntimeError is pin is not started
+        """
+        pass  # subclasses must override
+
+
+#
+# ####### Factory Methods
+#
+
+
+#
+# Pin id allows pins to be selected using a single string to
+# select from different underlying providers, numbering schemes and settings.
+#
+# Use Rpi.GPIO library, GPIO.BOARD pin numbering scheme, pin number 13
+# "RPI_GPIO.BOARD.13"
+#
+# Use Rpi.GPIO library, GPIO.BCM broadcom pin numbering scheme, gpio pin number 33
+# "RPI_GPIO.BCM.33"
+#
+# Use PCA9685 on bus 0 at address 0x40, channel 7
+# "PCA9685.0:40.7"
+#
+def output_pin_by_id(pin_id: str, frequency_hz: int = 60) -> OutputPin:
+    """
+    Select a ttl output pin given a pin id.
+    :param pin_id: pin specifier string
+    :param frequency_hz: duty cycle frequency in hertz (only necessary for PCA9685)
+    :return: OutputPin
+    """
+    parts = pin_id.split(".")
+    if parts[0] == PinProvider.PCA9685:
+        pin_provider = parts[0]
+        i2c_bus, i2c_address = parts[1].split(":")
+        i2c_bus = int(i2c_bus)
+        i2c_address = int(i2c_address, base=16)
+        frequency_hz = int(frequency_hz)
+        pin_number = int(parts[2])
+        return output_pin(pin_provider, pin_number, i2c_bus=i2c_bus, i2c_address=i2c_address, frequency_hz=frequency_hz)
+
+    if parts[0] == PinProvider.RPI_GPIO:
+        pin_provider = parts[0]
+        pin_scheme = parts[1]
+        pin_number = int(parts[2])
+        return output_pin(pin_provider, pin_number, pin_scheme=pin_scheme)
+
+    if parts[0] == PinProvider.PIGPIO:
+        pin_provider = parts[0]
+        if PinScheme.BCM != parts[1]:
+            raise ValueError("Pin scheme must be BCM for PIGPIO")
+        pin_number = int(parts[2])
+        return output_pin(pin_provider, pin_number, pin_scheme=PinScheme.BCM)
+
+    raise ValueError(f"Unknown pin provider {parts[0]}")
+
+
+def pwm_pin_by_id(pin_id: str, frequency_hz: int = 60) -> PwmPin:
+    """
+    Select a pwm output pin given a pin id.
+    :param pin_id: pin specifier string
+    :param frequency_hz: duty cycle frequency in hertz
+    :return: PwmPin
+    """
+    parts = pin_id.split(".")
+    if parts[0] == PinProvider.PCA9685:
+        pin_provider = parts[0]
+        i2c_bus, i2c_address = parts[1].split(":")
+        i2c_bus = int(i2c_bus)
+        i2c_address = int(i2c_address, base=16)
+        pin_number = int(parts[2])
+        return pwm_pin(pin_provider, pin_number, i2c_bus=i2c_bus, i2c_address=i2c_address, frequency_hz=frequency_hz)
+
+    if parts[0] == PinProvider.RPI_GPIO:
+        pin_provider = parts[0]
+        pin_scheme = parts[1]
+        pin_number = int(parts[2])
+        return pwm_pin(pin_provider, pin_number, pin_scheme=pin_scheme, frequency_hz=frequency_hz)
+
+    if parts[0] == PinProvider.PIGPIO:
+        pin_provider = parts[0]
+        if PinScheme.BCM != parts[1]:
+            raise ValueError("Pin scheme must be BCM for PIGPIO")
+        pin_number = int(parts[2])
+        return pwm_pin(pin_provider, pin_number, pin_scheme=PinScheme.BCM, frequency_hz=frequency_hz)
+
+    raise ValueError(f"Unknown pin provider {parts[0]}")
+
+
+def input_pin_by_id(pin_id: str, pull: int = PinPull.PULL_NONE) -> InputPin:
+    """
+    Select a ttl input pin given a pin id.
+    """
+    parts = pin_id.split(".")
+    if parts[0] == PinProvider.PCA9685:
+        raise RuntimeError("PinProvider.PCA9685 does not implement InputPin")
+
+    if parts[0] == PinProvider.RPI_GPIO:
+        pin_provider = parts[0]
+        pin_scheme = parts[1]
+        pin_number = int(parts[2])
+        return input_pin(pin_provider, pin_number, pin_scheme=pin_scheme, pull=pull)
+
+    if parts[0] == PinProvider.PIGPIO:
+        pin_provider = parts[0]
+        if PinScheme.BCM != parts[1]:
+            raise ValueError("Pin scheme must be BCM for PIGPIO")
+        pin_number = int(parts[2])
+        return input_pin(pin_provider, pin_number, pin_scheme=PinScheme.BCM, pull=pull)
+
+    raise ValueError(f"Unknown pin provider {parts[0]}")
+
+
+def input_pin(
+        pin_provider: str,
+        pin_number: int,
+        pin_scheme: str = PinScheme.BOARD,
+        pull: int = PinPull.PULL_NONE) -> InputPin:
+    """
+    construct an InputPin using the given pin provider.
+    Note that PCA9685 can NOT provide an InputPin.
+    :param pin_provider: PinProvider string
+    :param pin_number: zero based pin number
+    :param pin_scheme: PinScheme string
+    :param pull: PinPull value
+    :return: InputPin
+    :except: RuntimeError if pin_provider is not valid.
+    """
+    if pin_provider == PinProvider.RPI_GPIO:
+        return InputPinGpio(pin_number, pin_scheme, pull)
+    if pin_provider == PinProvider.PCA9685:
+        raise RuntimeError("PinProvider.PCA9685 does not implement InputPin")
+    if pin_provider == PinProvider.PIGPIO:
+        if pin_scheme != PinScheme.BCM:
+            raise ValueError("Pin scheme must be PinScheme.BCM for PIGPIO")
+        return InputPinPigpio(pin_number, pull)
+    raise RuntimeError(f"UnknownPinProvider ({pin_provider})")
+
+
+def output_pin(
+        pin_provider: str,
+        pin_number: int,
+        pin_scheme: str = PinScheme.BOARD,
+        i2c_bus: int = 0,
+        i2c_address: int = 40,
+        frequency_hz: int = 60) -> OutputPin:
+    """
+    construct an OutputPin using the given pin provider
+    Note that PCA9685 can NOT provide an InputPin.
+    :param pin_provider: PinProvider string
+    :param pin_number: zero based pin number
+    :param pin_scheme: PinScheme string
+    :param i2c_bus: I2C bus number for I2C devices
+    :param i2c_address: I2C address for I2C devices
+    :param frequency_hz: duty cycle frequence in hertz (for PCA9685)
+    :return: InputPin
+    :except: RuntimeError if pin_provider is not valid.
+    """
+    if pin_provider == PinProvider.RPI_GPIO:
+        return OutputPinGpio(pin_number, pin_scheme)
+    if pin_provider == PinProvider.PCA9685:
+        return OutputPinPCA9685(pin_number, pca9685(i2c_bus, i2c_address, frequency_hz))
+    if pin_provider == PinProvider.PIGPIO:
+        if pin_scheme != PinScheme.BCM:
+            raise ValueError("Pin scheme must be PinScheme.BCM for PIGPIO")
+        return OutputPinPigpio(pin_number)
+    raise RuntimeError(f"UnknownPinProvider ({pin_provider})")
+
+
+def pwm_pin(
+        pin_provider: str,
+        pin_number: int,
+        pin_scheme: str = PinScheme.BOARD,
+        frequency_hz: int = 60,
+        i2c_bus: int = 0,
+        i2c_address: int = 40) -> PwmPin:
+    """
+    construct a PwmPin using the given pin provider
+    :param pin_provider: PinProvider string
+    :param pin_number: zero based pin number
+    :param pin_scheme: PinScheme string
+    :param i2c_bus: I2C bus number for I2C devices
+    :param i2c_address: I2C address for I2C devices
+    :param frequency_hz: duty cycle frequence in hertz
+    :return: PwmPin
+    :except: RuntimeError if pin_provider is not valid.
+    """
+    if pin_provider == PinProvider.RPI_GPIO:
+        return PwmPinGpio(pin_number, pin_scheme, frequency_hz)
+    if pin_provider == PinProvider.PCA9685:
+        return PwmPinPCA9685(pin_number, pca9685(i2c_bus, i2c_address, frequency_hz))
+    if pin_provider == PinProvider.PIGPIO:
+        if pin_scheme != PinScheme.BCM:
+            raise ValueError("Pin scheme must be PinScheme.BCM for PIGPIO")
+        return PwmPinPigpio(pin_number, frequency_hz)
+    raise RuntimeError(f"UnknownPinProvider ({pin_provider})")
+
+
+#
+# ----- RPi.GPIO/Jetson.GPIO implementations -----
+#
+try:
+    import RPi.GPIO as GPIO
+    # lookups to convert abstact api to GPIO values
+    gpio_pin_edge = [None, GPIO.RISING, GPIO.FALLING, GPIO.BOTH]
+    gpio_pin_pull = [None, GPIO.PUD_OFF, GPIO.PUD_DOWN, GPIO.PUD_UP]
+    gpio_pin_scheme = {PinScheme.BOARD: GPIO.BOARD, PinScheme.BCM: GPIO.BCM}
+except ImportError:
+    logger.warn("RPi.GPIO was not imported.")
+    globals()["GPIO"] = None
+
+
+def gpio_fn(pin_scheme:int, fn:Callable[[], Any]):
+    """
+    Convenience method to enforce the desired GPIO pin scheme
+    before calling a GPIO function.
+    RPi.GPIO allows only a single scheme to be set at runtime.
+    If the pin scheme is already set to a different scheme, then
+    this will raise a RuntimeError to prevent erroneous pin outputs.
+
+    :param pin_scheme:int GPIO.BOARD or GPIO.BCM
+    :param fn:Callable[[], Any] no-arg function to call after setting pin scheme.
+    :return:any return value from called function
+    :exception:RuntimeError if pin scheme is already set to a different scheme.
+    """
+    prev_scheme = GPIO.getmode()
+    if prev_scheme is None:
+        GPIO.setmode(pin_scheme)
+    elif prev_scheme != pin_scheme:
+        raise RuntimeError(f"Attempt to change GPIO pin scheme from ({prev_scheme}) to ({pin_scheme})"
+                           " after it has been set.  All RPi.GPIO user must use the same pin scheme.")
+    val = fn()
+    return val
+
+
+class InputPinGpio(InputPin):
+    def __init__(self, pin_number: int, pin_scheme: str, pull: int = PinPull.PULL_NONE) -> None:
+        """
+        Input pin ttl HIGH/LOW using RPi.GPIO/Jetson.GPIO
+        :param pin_number: GPIO.BOARD mode point number
+        :param pull: enable a pull up or down resistor on pin.  Default is PinPull.PULL_NONE
+        """
+        self.pin_number = pin_number
+        self.pin_scheme = gpio_pin_scheme[pin_scheme]
+        self.pull = pull
+        self.on_input = None
+        self._state = PinState.NOT_STARTED
+        super().__init__()
+
+    def _callback(self, pin_number):
+        if self.on_input is not None:
+            self.on_input(self.pin_number, self.input())
+
+    def start(self, on_input=None, edge=PinEdge.RISING) -> None:
+        """
+        :param on_input: function to call when an edge is detected, or None to ignore
+        :param edge: type of edge(s) that trigger on_input; default is
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start InputPinGpio({self.pin_number}) that is already started.")
+        gpio_fn(self.pin_scheme, lambda: GPIO.setup(self.pin_number, GPIO.IN, pull_up_down=gpio_pin_pull[self.pull]))
+        if on_input is not None:
+            self.on_input = on_input
+            gpio_fn(
+                self.pin_scheme,
+                lambda: GPIO.add_event_detect(self.pin_number, gpio_pin_edge[edge], callback=self._callback))
+        self.input()  # read first state
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            gpio_fn(self.pin_scheme, lambda: GPIO.cleanup(self.pin_number))
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> int:
+        return self._state
+
+    def input(self) -> int:
+        self._state = gpio_fn(self.pin_scheme, lambda: GPIO.input(self.pin_number))
+        return self._state
+
+
+class OutputPinGpio(OutputPin):
+    """
+    Output pin ttl HIGH/LOW using Rpi.GPIO/Jetson.GPIO
+    """
+    def __init__(self, pin_number: int, pin_scheme: str) -> None:
+        self.pin_number = pin_number
+        self.pin_scheme = gpio_pin_scheme[pin_scheme]
+        self._state = PinState.NOT_STARTED
+
+    def start(self, state: int = PinState.LOW) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start OutputPinGpio({self.pin_number}) that is already started.")
+        gpio_fn(self.pin_scheme, lambda: GPIO.setup(self.pin_number, GPIO.OUT))
+        self.output(state)
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            gpio_fn(self.pin_scheme, lambda: GPIO.cleanup(self.pin_number))
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> int:
+        return self._state
+
+    def output(self, state: int) -> None:
+        gpio_fn(self.pin_scheme, lambda: GPIO.output(self.pin_number, state))
+        self._state = state
+
+
+class PwmPinGpio(PwmPin):
+    """
+    PWM output pin using Rpi.GPIO/Jetson.GPIO
+    """
+    def __init__(self, pin_number: int, pin_scheme: str, frequency_hz: float = 50) -> None:
+        self.pin_number = pin_number
+        self.pin_scheme = gpio_pin_scheme[pin_scheme]
+        self.frequency = int(frequency_hz)
+        self.pwm = None
+        self._state = PinState.NOT_STARTED
+
+    def start(self, duty: float = 0) -> None:
+        if self.pwm is not None:
+            raise RuntimeError("Attempt to start PwmPinGpio that is already started.")
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        gpio_fn(self.pin_scheme, lambda: GPIO.setup(self.pin_number, GPIO.OUT))
+        self.pwm = gpio_fn(self.pin_scheme, lambda: GPIO.PWM(self.pin_number, self.frequency))
+        self.pwm.start(duty * 100)  # takes duty in range 0 to 100
+        self._state = duty
+
+    def stop(self) -> None:
+        if self.pwm is not None:
+            self.pwm.stop()
+            gpio_fn(self.pin_scheme, lambda: GPIO.cleanup(self.pin_number))
+        self._state = PinState.NOT_STARTED
+
+    def state(self) -> float:
+        return self._state
+
+    def duty_cycle(self, duty: float) -> None:
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        self.pwm.ChangeDutyCycle(duty * 100)  # takes duty of 0 to 100
+        self._state = duty
+
+
+#
+# ----- PCA9685 implementations -----
+#
+class PCA9685:
+    '''
+    Pin controller using PCA9685 boards.
+    This is used for most RC Cars.  This
+    driver can output ttl HIGH or LOW or
+    produce a duty cycle at the given frequency.
+    '''
+    def __init__(self, busnum: int, address: int, frequency: int):
+
+        import Adafruit_PCA9685
+        if busnum is not None:
+            from Adafruit_GPIO import I2C
+
+            # monkey-patch I2C driver to use our bus number
+            def get_bus():
+                return busnum
+
+            I2C.get_default_bus = get_bus
+        self.pwm = Adafruit_PCA9685.PCA9685(address=address)
+        self.pwm.set_pwm_freq(frequency)
+        self._frequency = frequency
+
+    def get_frequency(self):
+        return self._frequency
+
+    def set_high(self, channel: int):
+        self.pwm.set_pwm(channel, 4096, 0)
+
+    def set_low(self, channel: int):
+        self.pwm.set_pwm(channel, 0, 4096)
+
+    def set_duty_cycle(self, channel: int, duty_cycle: float):
+        if duty_cycle < 0 or duty_cycle > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        if duty_cycle == 1:
+            self.set_high(channel)
+        elif duty_cycle == 0:
+            self.set_low(channel)
+        else:
+            # duty cycle is fraction of the 12 bits
+            pulse = int(4096 * duty_cycle)
+            try:
+                self.pwm.set_pwm(channel, 0, pulse)
+            except Exception as e:
+                logger.error(f'Error on PCA9685 channel {channel}: {str(e)}')
+
+
+#
+# lookup map for PCA9685 singletons
+# key is "busnum:address"
+#
+_pca9685 = {}
+
+
+def pca9685(busnum: int, address: int, frequency: int = 60):
+    """
+    pca9685 factory allocates driver for pca9685
+    at given bus number and i2c address.
+    If we have already created one for that bus/addr
+    pair then use that singleton.  If frequency is
+    not the same, then error.
+    :param busnum: I2C bus number of PCA9685
+    :param address: address of PCA9685 on I2C bus
+    :param frequency: frequency in hertz of duty cycle
+    :except: PCA9685 has a single frequency for all channels,
+             so attempts to allocate a controller at a
+             given bus number and address with different
+             frequencies will raise a ValueError
+    """
+    key = str(busnum) + ":" + hex(address)
+    pca = _pca9685.get(key)
+    if pca is None:
+        pca = PCA9685(busnum, address, frequency)
+    if pca.get_frequency() != frequency:
+        raise ValueError(
+            f"Frequency {frequency} conflicts with pca9685 at {key} "
+            f"with frequency {pca.pwm.get_pwm_freq()}")
+    return pca
+
+
+class OutputPinPCA9685(ABC):
+    """
+    Output pin ttl HIGH/LOW using PCA9685
+    """
+    def __init__(self, pin_number: int, pca9685: PCA9685) -> None:
+        self.pin_number = pin_number
+        self.pca9685 = pca9685
+        self._state = PinState.NOT_STARTED
+
+    def start(self, state: int = PinState.LOW) -> None:
+        """
+        Start the pin in output mode.
+        This raises a RuntimeError if the pin is already started.
+        You can check to see if the pin is started by calling
+        state() and checking for PinState.NOT_STARTED
+        :param state: PinState to start with
+        :except: RuntimeError if pin is already started.
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start pin ({self.pin_number}) that is already started")
+        self._state = 0  # hack to allow first output to work
+        self.output(state)
+
+    def stop(self) -> None:
+        """
+        Stop the pin and return it to PinState.NOT_STARTED
+        """
+        if self.state() != PinState.NOT_STARTED:
+            self.output(PinState.LOW)
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> int:
+        """
+        Return most recent output state.
+        If the pin is not started or has been stopped,
+        this will return PinState:NOT_STARTED
+        :return: PinState
+        """
+        return self._state
+
+    def output(self, state: int) -> None:
+        """
+        Write output state to the pin.
+        :param state: PinState.LOW or PinState.HIGH
+        """
+        if self.state() == PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to use pin ({self.pin_number}) that is not started")
+        if state == PinState.HIGH:
+            self.pca9685.set_high(self.pin_number)
+        else:
+            self.pca9685.set_low(self.pin_number)
+        self._state = state
+
+
+class PwmPinPCA9685(PwmPin):
+    """
+    PWM output pin using PCA9685
+    """
+    def __init__(self, pin_number: int, pca9685: PCA9685) -> None:
+        self.pin_number = pin_number
+        self.pca9685 = pca9685
+        self._state = PinState.NOT_STARTED
+
+    def start(self, duty: float = 0) -> None:
+        """
+        Start pin with given duty cycle
+        :param duty: duty cycle in range 0 to 1
+        :except: RuntimeError if pin is already started.
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start pin ({self.pin_number}) that is already started")
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        self._state = 0  # hack to allow first duty_cycle to work
+        self.duty_cycle(duty)
+        self._state = duty
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            self.duty_cycle(0)
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> float:
+        """
+        This returns the last set duty cycle.
+        :return: duty cycle in range 0 to 1 OR PinState.NOT_STARTED in not started
+        """
+        return self._state
+
+    def duty_cycle(self, duty: float) -> None:
+        """
+        Write a duty cycle to the output pin
+        :param duty: duty cycle in range 0 to 1
+        :except: RuntimeError if not started
+        """
+        if self.state() == PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to use pin ({self.pin_number}) that is not started")
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        self.pca9685.set_duty_cycle(self.pin_number, duty)
+        self._state = duty
+
+
+#
+# ----- PIGPIO implementation -----
+#
+
+# pigpio is an optional install
+try:
+    import pigpio
+    pigpio_pin_edge = [None, pigpio.RISING_EDGE, pigpio.FALLING_EDGE, pigpio.EITHER_EDGE]
+    pigpio_pin_pull = [None, pigpio.PUD_OFF, pigpio.PUD_DOWN, pigpio.PUD_UP]
+except ImportError:
+    logger.warn("pigpio was not imported.")
+    globals()["pigpio"] = None
+
+
+class InputPinPigpio(InputPin):
+    def __init__(self, pin_number: int, pull: int = PinPull.PULL_NONE, pgpio=None) -> None:
+        """
+        Input pin ttl HIGH/LOW using PiGPIO library
+        :param pin_number: GPIO.BOARD mode pin number
+        :param pull: enable a pull up or down resistor on pin.  Default is PinPull.PULL_NONE
+        :param pgpio: instance of pgpio to use or None to allocate a new one
+        """
+        self.pgpio = pgpio
+        self.pin_number = pin_number
+        self.pull = pigpio_pin_pull[pull]
+        self.on_input = None
+        self._state = PinState.NOT_STARTED
+
+    def __del__(self):
+        self.stop()
+
+    def _callback(self, gpio, level, tock):
+        if self.on_input is not None:
+            self.on_input(gpio, level)
+
+    def start(self, on_input=None, edge=PinEdge.RISING) -> None:
+        """
+        Start the input pin and optionally set callback.
+        :param on_input: function to call when an edge is detected, or None to ignore
+        :param edge: type of edge(s) that trigger on_input; default is PinEdge.RISING
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start InputPinPigpio({self.pin_number}) that is already started.")
+
+        self.pgpio = self.pgpio or pigpio.pi()
+        self.pgpio.set_mode(self.pin_number, pigpio.INPUT)
+        self.pgpio.set_pull_up_down(self.pin_number, self.pull)
+
+        if on_input is not None:
+            self.on_input = on_input
+            self.pgpio.callback(self.pin_number, pigpio_pin_edge[edge], self._callback)
+        self._state = self.pgpio.read(self.pin_number)  # read initial state
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            self.pgpio.stop()
+            self.pgpio = None
+            self.on_input = None
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> int:
+        """
+        Return last input() value.  This does NOT read the input again;
+        it returns that last value that input() returned.
+        :return: PinState.LOW/HIGH OR PinState.NOT_STARTED if not started
+        """
+        return self._state
+
+    def input(self) -> int:
+        """
+        Read the input pins state.
+        :return: PinState.LOW/HIGH OR PinState.NOT_STARTED if not started
+        """
+        if self.state() != PinState.NOT_STARTED:
+            self._state = self.pgpio.read(self.pin_number)
+        return self._state
+
+
+class OutputPinPigpio(OutputPin):
+    """
+    Output pin ttl HIGH/LOW using Rpi.GPIO/Jetson.GPIO
+    """
+    def __init__(self, pin_number: int, pgpio=None) -> None:
+        self.pgpio = pgpio
+        self.pin_number = pin_number
+        self._state = PinState.NOT_STARTED
+
+    def start(self, state: int = PinState.LOW) -> None:
+        """
+        Start the pin in output mode.
+        This raises a RuntimeError if the pin is already started.
+        You can check to see if the pin is started by calling
+        state() and checking for PinState.NOT_STARTED
+        :param state: PinState to start with
+        :except: RuntimeError if pin is already started.
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError("Attempt to start OutputPin that is already started.")
+
+        self.pgpio = self.pgpio or pigpio.pi()
+        self.pgpio.set_mode(self.pin_number, pigpio.OUTPUT)
+        self.pgpio.write(self.pin_number, state)  # set initial state
+        self._state = state
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            self.pgpio.write(self.pin_number, PinState.LOW)
+            self.pgpio.stop()
+            self.pgpio = None
+            self._state = PinState.NOT_STARTED
+
+    def state(self) -> int:
+        """
+        Return last output state
+        :return: PinState.LOW/HIGH or PinState.NOT_STARTED if pin not started.
+        """
+        return self._state
+
+    def output(self, state: int) -> None:
+        """
+        Write output state to the pin.
+        :param state: PinState.LOW or PinState.HIGH
+        """
+        if self.state() != PinState.NOT_STARTED:
+            self.pgpio.write(self.pin_number, state)
+            self._state = state
+
+
+class PwmPinPigpio(PwmPin):
+    """
+    PWM output pin using Rpi.GPIO/Jetson.GPIO
+    """
+    def __init__(self, pin_number: int, frequency_hz: float = 50, pgpio=None) -> None:
+        self.pgpio = pgpio
+        self.pin_number: int = pin_number
+        self.frequency: int = int(frequency_hz)
+        self._state: int = PinState.NOT_STARTED
+
+    def start(self, duty: float = 0) -> None:
+        """
+        Start pin with given duty cycle.
+        :param duty: duty cycle in range 0 to 1
+        :except: RuntimeError if pin is already started.
+        """
+        if self.state() != PinState.NOT_STARTED:
+            raise RuntimeError(f"Attempt to start InputPinPigpio({self.pin_number}) that is already started.")
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        self.pgpio = self.pgpio or pigpio.pi()
+        self.pgpio.set_mode(self.pin_number, pigpio.OUTPUT)
+        self.pgpio.set_PWM_frequency(self.pin_number, self.frequency)
+        self.pgpio.set_PWM_range(self.pin_number, 4095)  # 12 bits, same as PCA9685
+        self.pgpio.set_PWM_dutycycle(self.pin_number, int(duty * 4095))  # set initial state
+        self._state = duty
+
+    def stop(self) -> None:
+        if self.state() != PinState.NOT_STARTED:
+            self.pgpio.write(self.pin_number, PinState.LOW)
+            self.pgpio.stop()
+            self.pgpio = None
+        self._state = PinState.NOT_STARTED
+
+    def state(self) -> float:
+        """
+        This returns the last set duty cycle.
+        :return: duty cycle in range 0 to 1 OR PinState.NOT_STARTED in not started
+        """
+        return self._state
+
+    def duty_cycle(self, duty: float) -> None:
+        """
+        Write a duty cycle to the output pin
+        :param duty: duty cycle in range 0 to 1
+        :except: RuntimeError if not started
+        """
+        if duty < 0 or duty > 1:
+            raise ValueError("duty_cycle must be in range 0 to 1")
+        if self.state() != PinState.NOT_STARTED:
+            self.pgpio.set_PWM_dutycycle(self.pin_number, int(duty * 4095))
+            self._state = duty
+
+
+if __name__ == '__main__':
+    import argparse
+    import sys
+    import time
+
+    #
+    # output 50% duty cycle on Rpi board pin 33 (equivalent to BCM.13) for 10 seconds
+    # python pins.py --pwm-pin=RPI_GPIO.BOARD.33 --duty=0.5 --time=10
+    #
+    # input on Rpi board pin 35 (equivalend to BCM.19) for 10 seconds
+    # python pins.py --in-pin=RPI_GPIO.BOARD.35 --time=10
+    #
+    # output 50% duty cycle on Rpi board pin 33, input on Rpi board pin 35 using interrupt handler
+    # python pins.py --pwm-pin=RPI_GPIO.BOARD.33 --duty=0.5 --in-pin=RPI_GPIO.BOARD.35 -int=rising --time=10
+    #
+    # output on Rpi board pin 33, input on Rpi board pin 35 using interrupt handler
+    # python pins.py --out-pin=RPI_GPIO.BOARD.33 --duty=0.5 --in-pin=RPI_GPIO.BOARD.35 -int=rising --time=10
+    #
+    #
+    # parse arguments
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-p", "--pwm-pin", type=str, default=None,
+                        help="pwm pin id, like 'PCA9685:1:60.13' or 'RPI_GPIO.BCM.13")
+    parser.add_argument("-hz", "--hertz", type=int, default=60,
+                        help="PWM signal frequence in hertz.  Default is 60hz")
+    parser.add_argument("-d", "--duty", type=float, default=0.5,
+                        help="duty cycle in range 0 to 1.  Default is 0.5")
+
+    parser.add_argument("-o", "--out-pin", type=str, default=None,
+                        help="ttl output pin id, like 'PCA9685:1:60.13' or 'RPI_GPIO.BOARD.35' or 'RPI_GPIO.BCM.13'")
+
+    parser.add_argument("-i", "--in-pin", type=str, default=None,
+                        help="ttl input pin id, like 'RPI_GPIO.BOARD.35' or 'RPI_GPIO.BCM.19'")
+    parser.add_argument("-pu", "--pull", type=str, choices=['up', 'down', 'none'], default='none',
+                        help="input pin pullup, one of 'up', 'down', 'none'")
+    parser.add_argument("-int", "--interrupt", type=str, choices=['falling', 'rising', 'both', 'none'], default='none',
+                        help="use interrupt routine on in-pin with given edge; 'falling', 'rising' or 'both'")
+    parser.add_argument("-tm", "--time", type=float, default=1, help="duration test in seconds")
+    parser.add_argument("-db", "--debug", action='store_true', help="show debug output")
+    parser.add_argument("-th", "--threaded", action='store_true', help="run in threaded mode")
+
+    # Read arguments from command line
+    args = parser.parse_args()
+
+    help = []
+    if args.hertz < 1:
+        help.append("-hz/--hertz: must be >= 1.")
+
+    if args.duty < 0 or args.duty > 1:
+        help.append("-d/--duty: must be in range 0 to 1")
+
+    if args.pwm_pin is None and args.out_pin is None and args.in_pin is None:
+        help.append("must have one of -o/--out-pin or -p/--pwm-pin or -i/--in-pin")
+
+    if args.pwm_pin is not None and args.out_pin is not None:
+        help.append("must have only one of -o/--out-pin or -p/--pwn-pin")
+
+    if args.time < 1:
+        help.append("-tm/--time: must be > 0.")
+
+    if len(help) > 0:
+        parser.print_help()
+        for h in help:
+            print("  " + h)
+        sys.exit(1)
+
+    pin_pull = {
+        'up': PinPull.PULL_UP,
+        'down': PinPull.PULL_DOWN,
+        'none': PinPull.PULL_NONE
+    }
+    pin_edge = {
+        'none': None,
+        'falling': PinEdge.FALLING,
+        'rising': PinEdge.RISING,
+        'both': PinEdge.BOTH
+    }
+
+    def on_input(pin_number, state):
+        if state == PinState.HIGH:
+            print("+", pin_number, time.time()*1000)
+        elif state == PinState.LOW:
+            print("-", pin_number, time.time()*1000)
+
+    pwm_out_pin: PwmPin = None
+    ttl_out_pin: OutputPin = None
+    ttl_in_pin: InputPin = None
+    try:
+        #
+        # construct a pin of the correct type
+        #
+        if args.in_pin is not None:
+            ttl_in_pin = input_pin_by_id(args.in_pin, pin_pull[args.pull])
+            if args.interrupt != 'none':
+                ttl_in_pin.start(on_input=on_input, edge=pin_edge[args.interrupt])
+            else:
+                ttl_in_pin.start()
+
+        if args.pwm_pin is not None:
+            pwm_out_pin = pwm_pin_by_id(args.pwm_pin, args.hertz)
+            pwm_out_pin.start(args.duty)
+
+        if args.out_pin is not None:
+            ttl_out_pin = output_pin_by_id(args.out_pin, args.hertz)
+            ttl_out_pin.start(PinState.LOW)
+
+        start_time = time.time()
+        end_time = start_time + args.time
+        while start_time < end_time:
+            if ttl_out_pin is not None:
+                if args.duty > 0:
+                    ttl_out_pin.output(PinState.HIGH)
+                    time.sleep(1 / args.hertz * args.duty)
+                if args.duty < 1:
+                    ttl_out_pin.output(PinState.LOW)
+                    time.sleep(1 / args.hertz * (1 - args.duty))
+            else:
+                # yield time to background threads
+                sleep_time = 1/args.hertz - (time.time() - start_time)
+                if sleep_time > 0.0:
+                    time.sleep(sleep_time)
+                else:
+                    time.sleep(0)  # yield time to other threads
+            start_time = time.time()
+
+    except KeyboardInterrupt:
+        print('Stopping early.')
+    except Exception as e:
+        print(e)
+        exit(1)
+    finally:
+        if pwm_out_pin is not None:
+            pwm_out_pin.stop()
+        if ttl_out_pin is not None:
+            ttl_out_pin.stop()
diff --git a/donkeycar/parts/web_controller/templates/calibrate.html b/donkeycar/parts/web_controller/templates/calibrate.html
index cf5bab87b..22d82151c 100644
--- a/donkeycar/parts/web_controller/templates/calibrate.html
+++ b/donkeycar/parts/web_controller/templates/calibrate.html
@@ -23,7 +23,7 @@ <h1>Donkey Real-time calibration</h1>
             <div class="col">
                 <h2>Introduction</h2>
                 <p> This page allow you to dynamically re-adjust the calibration
-                    value of PCA9685 or MM1. Please note that after finding the
+                    value of PulseController, PCA9685 or MM1. Please note that after finding the
                     desire value, you need to update the value in myconfig.py manually </p>
                 <h2>Instruction</h2>
 
@@ -38,10 +38,10 @@ <h3>Throttle</h3>
                 <form name="calibrate_form">
                     <fieldset>
 
-                        <legend>PCA9685</legend>
+                        <legend>Servo/ESC PWM Pulse Configuration</legend>
                         <div>
                             <label for="STEERING_LEFT_PWM">
-                                <span>STEERING_LEFT_PWM:</span>
+                                <span>Left Steering Pulse:</span>
                                 <input name="STEERING_LEFT_PWM" type="text" value="460"> </input>
                                 <input type="button" value="+">
                                 <input type="button" value="-">
@@ -49,7 +49,7 @@ <h3>Throttle</h3>
                         </div>
                         <div>
                             <label for="STEERING_RIGHT_PWM">
-                                <span>STEERING_RIGHT_PWM:</span>
+                                <span>Right Steering Pulse:</span>
                                 <input name="STEERING_RIGHT_PWM" type="text" value="290"> </input>
                                 <input type="button" value="+">
                                 <input type="button" value="-">
@@ -57,7 +57,7 @@ <h3>Throttle</h3>
                         </div>
                         <p>
                             <label for="THROTTLE_FORWARD_PWM">
-                                <span>THROTTLE_FORWARD_PWM:</span>
+                                <span>Forward Throttle Pulse:</span>
                                 <input name="THROTTLE_FORWARD_PWM" type="text" value="500"> </input>
                                 <input type="button" value="+">
                                 <input type="button" value="-">
diff --git a/donkeycar/parts/web_controller/web.py b/donkeycar/parts/web_controller/web.py
index 6b7726ae4..c35237a05 100644
--- a/donkeycar/parts/web_controller/web.py
+++ b/donkeycar/parts/web_controller/web.py
@@ -246,7 +246,8 @@ def on_message(self, message):
 
         if 'config' in data:
             config = data['config']
-            if self.application.drive_train_type == "I2C_SERVO":
+            if self.application.drive_train_type == "PWM_STEERING_THROTTLE" \
+                or self.application.drive_train_type == "I2C_SERVO":
                 if 'STEERING_LEFT_PWM' in config:
                     self.application.drive_train['steering'].left_pulse = config['STEERING_LEFT_PWM']
 
diff --git a/donkeycar/templates/calibrate.py b/donkeycar/templates/calibrate.py
index b65c70777..cb27183cf 100644
--- a/donkeycar/templates/calibrate.py
+++ b/donkeycar/templates/calibrate.py
@@ -20,6 +20,7 @@
 from donkeycar.parts.controller import LocalWebController, \
     JoystickController, WebFpv
 from donkeycar.parts.throttle_filter import ThrottleFilter
+from donkeycar.parts import pins
 from donkeycar.utils import *
 
 from socket import gethostname
@@ -54,6 +55,37 @@ def drive(cfg ):
     if cfg.DONKEY_GYM or cfg.DRIVE_TRAIN_TYPE == "MOCK":
         pass
 
+    elif cfg.DRIVE_TRAIN_TYPE == "PWM_STEERING_THROTTLE":
+        #
+        # drivetrain for RC car with servo and ESC.
+        # using a PwmPin for steering (servo)
+        # and as second PwmPin for throttle (ESC)
+        #
+        from donkeycar.parts.actuator import PWMSteering, PWMThrottle, PulseController
+        steering_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_STEERING_PIN),
+            pwm_scale=cfg.PWM_STEERING_SCALE, 
+            pwm_inverted=cfg.PWM_STEERING_INVERTED)
+        steering = PWMSteering(controller=steering_controller,
+                                        left_pulse=cfg.STEERING_LEFT_PWM, 
+                                        right_pulse=cfg.STEERING_RIGHT_PWM)
+        
+        throttle_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_THROTTLE_PIN), 
+            pwm_scale=cfg.PWM_THROTTLE_SCALE, 
+            pwm_inverted=cfg.PWM_THROTTLE_INVERTED)
+        throttle = PWMThrottle(controller=throttle_controller,
+                                            max_pulse=cfg.THROTTLE_FORWARD_PWM,
+                                            zero_pulse=cfg.THROTTLE_STOPPED_PWM, 
+                                            min_pulse=cfg.THROTTLE_REVERSE_PWM)
+
+        drive_train = dict()
+        drive_train['steering'] = steering
+        drive_train['throttle'] = throttle
+
+        V.add(steering, inputs=['angle'], threaded=True)
+        V.add(throttle, inputs=['throttle'], threaded=True)
+
     elif cfg.DRIVE_TRAIN_TYPE == "I2C_SERVO":
 
         from donkeycar.parts.actuator import PCA9685, PWMSteering, PWMThrottle
diff --git a/donkeycar/templates/cfg_complete.py b/donkeycar/templates/cfg_complete.py
index 5d1602e6c..719ea78dd 100644
--- a/donkeycar/templates/cfg_complete.py
+++ b/donkeycar/templates/cfg_complete.py
@@ -48,49 +48,244 @@
 SSD1306_128_32_I2C_ROTATION = 0 # 0 = text is right-side up, 1 = rotated 90 degrees clockwise, 2 = 180 degrees (flipped), 3 = 270 degrees
 SSD1306_RESOLUTION = 1 # 1 = 128x32; 2 = 128x64
 
-#DRIVETRAIN
-#These options specify which chasis and motor setup you are using. Most are using I2C_SERVO.
-#DC_STEER_THROTTLE uses HBridge pwm to control one steering dc motor, and one drive wheel motor
-#DC_TWO_WHEEL uses HBridge pwm to control two drive motors, one on the left, and one on the right.
-#SERVO_HBRIDGE_PWM use ServoBlaster to output pwm control from the PiZero directly to control steering, and HBridge for a drive motor.
-#PIGPIO_PWM uses Raspberrys internal PWM
-DRIVE_TRAIN_TYPE = "I2C_SERVO" # I2C_SERVO|DC_STEER_THROTTLE|DC_TWO_WHEEL|DC_TWO_WHEEL_L298N|SERVO_HBRIDGE_PWM|PIGPIO_PWM|MM1|MOCK
-
-#STEERING
-STEERING_CHANNEL = 1            #channel on the 9685 pwm board 0-15
+#
+# DRIVE_TRAIN_TYPE
+# These options specify which chasis and motor setup you are using.
+# See Actuators documentation https://docs.donkeycar.com/parts/actuators/
+# for a detailed explanation of each drive train type and it's configuration.
+# Choose one of the following and then update the related configuration section:
+#
+# "PWM_STEERING_THROTTLE" uses two PWM output pins to control a steering servo and an ESC, as in a standard RC car.
+# "MM1" Robo HAT MM1 board
+# "SERVO_HBRIDGE_2PIN" Servo for steering and HBridge motor driver in 2pin mode for motor
+# "SERVO_HBRIDGE_3PIN" Servo for steering and HBridge motor driver in 3pin mode for motor
+# "DC_STEER_THROTTLE" uses HBridge pwm to control one steering dc motor, and one drive wheel motor
+# "DC_TWO_WHEEL" uses HBridge in two-pin mode to control two drive motors, one on the left, and one on the right.
+# "DC_TWO_WHEEL_L298N" using HBridge in three-pin mode to control two drive motors, one of the left and one on the right.
+# "MOCK" no drive train.  This can be used to test other features in a test rig.
+# (deprecated) "SERVO_HBRIDGE_PWM" use ServoBlaster to output pwm control from the PiZero directly to control steering, and HBridge for a drive motor.
+# (deprecated) "PIGPIO_PWM" uses Raspberrys internal PWM
+# (deprecated) "I2C_SERVO" uses PCA9685 servo controller to control a steering servo and an ESC, as in a standard RC car
+#
+DRIVE_TRAIN_TYPE = "PWM_STEERING_THROTTLE"
+
+#
+# PWM_STEERING_THROTTLE
+#
+# Drive train for RC car with a steering servo and ESC.
+# Uses a PwmPin for steering (servo) and a second PwmPin for throttle (ESC)
+# Base PWM Frequence is presumed to be 60hz; use PWM_xxxx_SCALE to adjust pulse with for non-standard PWM frequencies
+#
+PWM_STEERING_PIN = "PCA9685.1:40.1"  # PWM output pin for steering servo
+PWM_STEERING_SCALE = 1.0   # used to compensate for PWM frequency differents from 60hz; NOT for adjusting steering range
+PWM_STEERING_INVERTED = False  # True if hardware requires an inverted PWM pulse
+PWM_THROTTLE_PIN = "PCA9685.1:40.0"  # PWM output pin for ESC
+PWM_THROTTLE_SCALE = 1.0   # used to compensate for PWM frequence differences from 60hz; NOT for increasing/limiting speed
+PWM_THROTTLE_INVERTED = False  # True if hardware requires an inverted PWM pulse
+
+
+#STEERING FOR PWM_STEERING_THROTTLE (and deprecated I2C_SERVO)
+STEERING_CHANNEL = 1            #(deprecated) channel on the 9685 pwm board 0-15
 STEERING_LEFT_PWM = 460         #pwm value for full left steering
 STEERING_RIGHT_PWM = 290        #pwm value for full right steering
 
-#STEERING FOR PIGPIO_PWM OUTPUT
-STEERING_PWM_PIN = 13           #Pin numbering according to Broadcom numbers
+#STEERING FOR PWM_STEERING_THROTTLE (and deprecated PIGPIO_PWM OUTPUT)
+STEERING_PWM_PIN = 13           #(deprecated) Pin numbering according to Broadcom numbers
 STEERING_PWM_FREQ = 50          #Frequency for PWM
 STEERING_PWM_INVERTED = False   #If PWM needs to be inverted
 
-#THROTTLE
-THROTTLE_CHANNEL = 0            #channel on the 9685 pwm board 0-15
+#THROTTLE FOR PWM_STEERING_THROTTLE (and deprecated I2C_SERVO)
+THROTTLE_CHANNEL = 0            #(deprecated) channel on the 9685 pwm board 0-15
 THROTTLE_FORWARD_PWM = 500      #pwm value for max forward throttle
 THROTTLE_STOPPED_PWM = 370      #pwm value for no movement
 THROTTLE_REVERSE_PWM = 220      #pwm value for max reverse throttle
 
-#THROTTLE FOR PIGPIO_PWM OUTPUT
-THROTTLE_PWM_PIN = 18           #Pin numbering according to Broadcom numbers
+#THROTTLE FOR PWM_STEERING_THROTTLE (and deprecated PIGPIO_PWM OUTPUT)
+THROTTLE_PWM_PIN = 18           #(deprecated) Pin numbering according to Broadcom numbers
 THROTTLE_PWM_FREQ = 50          #Frequency for PWM
 THROTTLE_PWM_INVERTED = False   #If PWM needs to be inverted
 
-#DC_STEER_THROTTLE with one motor as steering, one as drive
-#these GPIO pinouts are only used for the DRIVE_TRAIN_TYPE=DC_STEER_THROTTLE
-HBRIDGE_PIN_LEFT = 18
-HBRIDGE_PIN_RIGHT = 16
-HBRIDGE_PIN_FWD = 15
-HBRIDGE_PIN_BWD = 13
-
-#DC_TWO_WHEEL - with two wheels as drive, left and right.
-#these GPIO pinouts are only used for the DRIVE_TRAIN_TYPE=DC_TWO_WHEEL
-HBRIDGE_PIN_LEFT_FWD = 18
-HBRIDGE_PIN_LEFT_BWD = 16
-HBRIDGE_PIN_RIGHT_FWD = 15
-HBRIDGE_PIN_RIGHT_BWD = 13
-
+#
+# SERVO_HBRIDGE_2PIN
+# - configures a steering servo and an HBridge in 2pin mode (2 pwm pins)
+# - Servo takes a standard servo PWM pulse between 1 millisecond (fully reverse)
+#   and 2 milliseconds (full forward) with 1.5ms being neutral.
+# - the motor is controlled by two pwm pins, 
+#   one for forward and one for backward (reverse). 
+# - the pwm pin produces a duty cycle from 0 (completely LOW)
+#   to 1 (100% completely high), which is proportional to the
+#   amount of power delivered to the motor.
+# - in forward mode, the reverse pwm is 0 duty_cycle,
+#   in backward mode, the forward pwm is 0 duty cycle.
+# - both pwms are 0 duty cycle (LOW) to 'detach' motor and 
+#   and glide to a stop.
+# - both pwms are full duty cycle (100% HIGH) to brake
+#
+# Pin specifier string format:
+# - use RPI_GPIO for RPi/Nano header pin output
+#   - use BOARD for board pin numbering
+#   - use BCM for Broadcom GPIO numbering
+#   - for example "RPI_GPIO.BOARD.18"
+# - use PIPGIO for RPi header pin output using pigpio server
+#   - must use BCM (broadcom) pin numbering scheme
+#   - for example, "PIGPIO.BCM.13"
+# - use PCA9685 for PCA9685 pin output
+#   - include colon separated I2C channel and address 
+#   - for example "PCA9685.1:40.13"
+# - RPI_GPIO, PIGPIO and PCA9685 can be mixed arbitrarily,
+#   although it is discouraged to mix RPI_GPIO and PIGPIO.
+#
+HBRIDGE_2PIN_DUTY_FWD = "RPI_GPIO.BOARD.18"  # provides forward duty cycle to motor
+HBRIDGE_2PIN_DUTY_BWD = "RPI_GPIO.BOARD.16"  # provides reverse duty cycle to motor
+PWM_STEERING_PIN = "RPI_GPIO.BOARD.33"       # provides servo pulse to steering servo
+PWM_STEERING_SCALE = 1.0        # used to compensate for PWM frequency differents from 60hz; NOT for adjusting steering range
+PWM_STEERING_INVERTED = False   # True if hardware requires an inverted PWM pulse
+STEERING_LEFT_PWM = 460         # pwm value for full left steering (use `donkey calibrate` to measure value for your car)
+STEERING_RIGHT_PWM = 290        # pwm value for full right steering (use `donkey calibrate` to measure value for your car)
+
+#
+# SERVO_HBRIDGE_3PIN
+# - configures a steering servo and an HBridge in 3pin mode (2 ttl pins, 1 pwm pin)
+# - Servo takes a standard servo PWM pulse between 1 millisecond (fully reverse)
+#   and 2 milliseconds (full forward) with 1.5ms being neutral.
+# - the motor is controlled by three pins, 
+#   one ttl output for forward, one ttl output 
+#   for backward (reverse) enable and one pwm pin
+#   for motor power.
+# - the pwm pin produces a duty cycle from 0 (completely LOW)
+#   to 1 (100% completely high), which is proportional to the
+#   amount of power delivered to the motor.
+# - in forward mode, the forward pin  is HIGH and the
+#   backward pin is LOW,
+# - in backward mode, the forward pin is LOW and the 
+#   backward pin is HIGH.
+# - both forward and backward pins are LOW to 'detach' motor 
+#   and glide to a stop.
+# - both forward and backward pins are HIGH to brake
+#
+# Pin specifier string format:
+# - use RPI_GPIO for RPi/Nano header pin output
+#   - use BOARD for board pin numbering
+#   - use BCM for Broadcom GPIO numbering
+#   - for example "RPI_GPIO.BOARD.18"
+# - use PIPGIO for RPi header pin output using pigpio server
+#   - must use BCM (broadcom) pin numbering scheme
+#   - for example, "PIGPIO.BCM.13"
+# - use PCA9685 for PCA9685 pin output
+#   - include colon separated I2C channel and address 
+#   - for example "PCA9685.1:40.13"
+# - RPI_GPIO, PIGPIO and PCA9685 can be mixed arbitrarily,
+#   although it is discouraged to mix RPI_GPIO and PIGPIO.
+#
+HBRIDGE_3PIN_FWD = "RPI_GPIO.BOARD.18"   # ttl pin, high enables motor forward 
+HBRIDGE_3PIN_BWD = "RPI_GPIO.BOARD.16"   # ttl pin, highenables motor reverse
+HBRIDGE_3PIN_DUTY = "RPI_GPIO.BOARD.35"  # provides duty cycle to motor
+PWM_STEERING_PIN = "RPI_GPIO.BOARD.33"   # provides servo pulse to steering servo
+PWM_STEERING_SCALE = 1.0        # used to compensate for PWM frequency differents from 60hz; NOT for adjusting steering range
+PWM_STEERING_INVERTED = False   # True if hardware requires an inverted PWM pulse
+STEERING_LEFT_PWM = 460         # pwm value for full left steering (use `donkey calibrate` to measure value for your car)
+STEERING_RIGHT_PWM = 290        # pwm value for full right steering (use `donkey calibrate` to measure value for your car)
+
+#
+# DC_STEER_THROTTLE with one motor as steering, one as drive
+# - uses L298N type motor controller in two pin wiring
+#   scheme utilizing two pwm pins per motor; one for 
+#   forward(or right) and one for reverse (or left)
+# 
+# GPIO pin configuration for the DRIVE_TRAIN_TYPE=DC_STEER_THROTTLE
+# - use RPI_GPIO for RPi/Nano header pin output
+#   - use BOARD for board pin numbering
+#   - use BCM for Broadcom GPIO numbering
+#   - for example "RPI_GPIO.BOARD.18"
+# - use PIPGIO for RPi header pin output using pigpio server
+#   - must use BCM (broadcom) pin numbering scheme
+#   - for example, "PIGPIO.BCM.13"
+# - use PCA9685 for PCA9685 pin output
+#   - include colon separated I2C channel and address 
+#   - for example "PCA9685.1:40.13"
+# - RPI_GPIO, PIGPIO and PCA9685 can be mixed arbitrarily,
+#   although it is discouraged to mix RPI_GPIO and PIGPIO.
+#
+HBRIDGE_PIN_LEFT = "RPI_GPIO.BOARD.18"   # pwm pin produces duty cycle for steering left
+HBRIDGE_PIN_RIGHT = "RPI_GPIO.BOARD.16"  # pwm pin produces duty cycle for steering right
+HBRIDGE_PIN_FWD = "RPI_GPIO.BOARD.15"    # pwm pin produces duty cycle for forward drive
+HBRIDGE_PIN_BWD = "RPI_GPIO.BOARD.13"    # pwm pin produces duty cycle for reverse drive
+
+#
+# DC_TWO_WHEEL pin configuration
+# - configures L298N_HBridge_2pin driver
+# - two wheels as differential drive, left and right.
+# - each wheel is controlled by two pwm pins, 
+#   one for forward and one for backward (reverse). 
+# - each pwm pin produces a duty cycle from 0 (completely LOW)
+#   to 1 (100% completely high), which is proportional to the
+#   amount of power delivered to the motor.
+# - in forward mode, the reverse pwm is 0 duty_cycle,
+#   in backward mode, the forward pwm is 0 duty cycle.
+# - both pwms are 0 duty cycle (LOW) to 'detach' motor and 
+#   and glide to a stop.
+# - both pwms are full duty cycle (100% HIGH) to brake
+#
+# Pin specifier string format:
+# - use RPI_GPIO for RPi/Nano header pin output
+#   - use BOARD for board pin numbering
+#   - use BCM for Broadcom GPIO numbering
+#   - for example "RPI_GPIO.BOARD.18"
+# - use PIPGIO for RPi header pin output using pigpio server
+#   - must use BCM (broadcom) pin numbering scheme
+#   - for example, "PIGPIO.BCM.13"
+# - use PCA9685 for PCA9685 pin output
+#   - include colon separated I2C channel and address 
+#   - for example "PCA9685.1:40.13"
+# - RPI_GPIO, PIGPIO and PCA9685 can be mixed arbitrarily,
+#   although it is discouraged to mix RPI_GPIO and PIGPIO.
+#
+HBRIDGE_PIN_LEFT_FWD = "RPI_GPIO.BOARD.18"  # pwm pin produces duty cycle for left wheel forward 
+HBRIDGE_PIN_LEFT_BWD = "RPI_GPIO.BOARD.16"  # pwm pin produces duty cycle for left wheel reverse
+HBRIDGE_PIN_RIGHT_FWD = "RPI_GPIO.BOARD.15" # pwm pin produces duty cycle for right wheel forward
+HBRIDGE_PIN_RIGHT_BWD = "RPI_GPIO.BOARD.13" # pwm pin produces duty cycle for right wheel reverse
+
+
+#
+# DC_TWO_WHEEL_L298N pin configuration
+# - configures L298N_HBridge_3pin driver
+# - two wheels as differential drive, left and right.
+# - each wheel is controlled by three pins, 
+#   one ttl output for forward, one ttl output 
+#   for backward (reverse) enable and one pwm pin
+#   for motor power.
+# - the pwm pin produces a duty cycle from 0 (completely LOW)
+#   to 1 (100% completely high), which is proportional to the
+#   amount of power delivered to the motor.
+# - in forward mode, the forward pin  is HIGH and the
+#   backward pin is LOW,
+# - in backward mode, the forward pin is LOW and the 
+#   backward pin is HIGH.
+# - both forward and backward pins are LOW to 'detach' motor 
+#   and glide to a stop.
+# - both forward and backward pins are HIGH to brake
+#
+# GPIO pin configuration for the DRIVE_TRAIN_TYPE=DC_TWO_WHEEL_L298N
+# - use RPI_GPIO for RPi/Nano header pin output
+#   - use BOARD for board pin numbering
+#   - use BCM for Broadcom GPIO numbering
+#   - for example "RPI_GPIO.BOARD.18"
+# - use PIPGIO for RPi header pin output using pigpio server
+#   - must use BCM (broadcom) pin numbering scheme
+#   - for example, "PIGPIO.BCM.13"
+# - use PCA9685 for PCA9685 pin output
+#   - include colon separated I2C channel and address 
+#   - for example "PCA9685.1:40.13"
+# - RPI_GPIO, PIGPIO and PCA9685 can be mixed arbitrarily,
+#   although it is discouraged to mix RPI_GPIO and PIGPIO.
+#
+HBRIDGE_L298N_PIN_LEFT_FWD = "RPI_GPIO.BOARD.16"  # TTL output pin enables left wheel forward
+HBRIDGE_L298N_PIN_LEFT_BWD = "RPI_GPIO.BOARD.18"  # TTL output pin enables left wheel reverse
+HBRIDGE_L298N_PIN_LEFT_EN = "RPI_GPIO.BOARD.22"   # PWM pin generates duty cycle for left motor speed
+
+HBRIDGE_L298N_PIN_RIGHT_FWD = "RPI_GPIO.BOARD.15" # TTL output pin enables right wheel forward
+HBRIDGE_L298N_PIN_RIGHT_BWD = "RPI_GPIO.BOARD.13" # TTL output pin enables right wheel reverse
+HBRIDGE_L298N_PIN_RIGHT_EN = "RPI_GPIO.BOARD.11"  # PWM pin generates duty cycle for right wheel speed
 
 #ODOMETRY
 HAVE_ODOM = False                   # Do you have an odometer/encoder 
@@ -105,16 +300,6 @@
 LIDAR_LOWER_LIMIT = 90 # angles that will be recorded. Use this to block out obstructed areas on your car, or looking backwards. Note that for the RP A1M8 Lidar, "0" is in the direction of the motor
 LIDAR_UPPER_LIMIT = 270
 
-#DC_TWO_WHEEL_L298N - with two wheels as drive, left and right.
-#these GPIO pinouts are only used for the DRIVE_TRAIN_TYPE=DC_TWO_WHEEL_L298N
-HBRIDGE_L298N_PIN_LEFT_FWD = 16
-HBRIDGE_L298N_PIN_LEFT_BWD = 18
-HBRIDGE_L298N_PIN_LEFT_EN = 22
-
-HBRIDGE_L298N_PIN_RIGHT_FWD = 15
-HBRIDGE_L298N_PIN_RIGHT_BWD = 13
-HBRIDGE_L298N_PIN_RIGHT_EN = 11
-
 #TRAINING
 # The default AI framework to use. Choose from (tensorflow|pytorch)
 DEFAULT_AI_FRAMEWORK = 'tensorflow'
diff --git a/donkeycar/templates/complete.py b/donkeycar/templates/complete.py
index c725127e7..2a4d76a4f 100644
--- a/donkeycar/templates/complete.py
+++ b/donkeycar/templates/complete.py
@@ -21,6 +21,7 @@
 
 
 import donkeycar as dk
+from donkeycar.parts import actuator, pins
 from donkeycar.parts.transform import TriggeredCallback, DelayedTrigger
 from donkeycar.parts.tub_v2 import TubWriter
 from donkeycar.parts.datastore import TubHandler
@@ -548,7 +549,37 @@ def run(self, mode, recording):
     # Drive train setup
     if cfg.DONKEY_GYM or cfg.DRIVE_TRAIN_TYPE == "MOCK":
         pass
+    elif cfg.DRIVE_TRAIN_TYPE == "PWM_STEERING_THROTTLE":
+        #
+        # drivetrain for RC car with servo and ESC.
+        # using a PwmPin for steering (servo)
+        # and as second PwmPin for throttle (ESC)
+        #
+        from donkeycar.parts.actuator import PWMSteering, PWMThrottle, PulseController
+        steering_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_STEERING_PIN),
+            pwm_scale=cfg.PWM_STEERING_SCALE, 
+            pwm_inverted=cfg.PWM_STEERING_INVERTED)
+        steering = PWMSteering(controller=steering_controller,
+                                        left_pulse=cfg.STEERING_LEFT_PWM, 
+                                        right_pulse=cfg.STEERING_RIGHT_PWM)
+        
+        throttle_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_THROTTLE_PIN), 
+            pwm_scale=cfg.PWM_THROTTLE_SCALE, 
+            pwm_inverted=cfg.PWM_THROTTLE_INVERTED)
+        throttle = PWMThrottle(controller=throttle_controller,
+                                            max_pulse=cfg.THROTTLE_FORWARD_PWM,
+                                            zero_pulse=cfg.THROTTLE_STOPPED_PWM, 
+                                            min_pulse=cfg.THROTTLE_REVERSE_PWM)
+        V.add(steering, inputs=['angle'], threaded=True)
+        V.add(throttle, inputs=['throttle'], threaded=True)
+
     elif cfg.DRIVE_TRAIN_TYPE == "I2C_SERVO":
+        #
+        # Thi driver is DEPRECATED in favor of 'DRIVE_TRAIN_TYPE == "PWM_STEERING_THROTTLE"'
+        # This driver will be removed in a future release
+        #
         from donkeycar.parts.actuator import PCA9685, PWMSteering, PWMThrottle
 
         steering_controller = PCA9685(cfg.STEERING_CHANNEL, cfg.PCA9685_I2C_ADDR, busnum=cfg.PCA9685_I2C_BUSNUM)
@@ -566,20 +597,25 @@ def run(self, mode, recording):
         V.add(throttle, inputs=['throttle'], threaded=True)
 
     elif cfg.DRIVE_TRAIN_TYPE == "DC_STEER_THROTTLE":
-        from donkeycar.parts.actuator import Mini_HBridge_DC_Motor_PWM
-
-        steering = Mini_HBridge_DC_Motor_PWM(cfg.HBRIDGE_PIN_LEFT, cfg.HBRIDGE_PIN_RIGHT)
-        throttle = Mini_HBridge_DC_Motor_PWM(cfg.HBRIDGE_PIN_FWD, cfg.HBRIDGE_PIN_BWD)
+        steering = actuator.L298N_HBridge_2pin(
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_LEFT), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_RIGHT))
+        throttle = Mini_HBridge_DC_Motor_PWM(
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_FWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_BWD))
 
         V.add(steering, inputs=['angle'])
         V.add(throttle, inputs=['throttle'])
 
     elif cfg.DRIVE_TRAIN_TYPE == "DC_TWO_WHEEL":
-        from donkeycar.parts.actuator import TwoWheelSteeringThrottle, Mini_HBridge_DC_Motor_PWM
+        left_motor = actuator.L298N_HBridge_2pin(
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_LEFT_FWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_LEFT_BWD))
+        right_motor = actuator.L298N_HBridge_2pin(
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_RIGHT_FWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_PIN_RIGHT_BWD))
 
-        left_motor = Mini_HBridge_DC_Motor_PWM(cfg.HBRIDGE_PIN_LEFT_FWD, cfg.HBRIDGE_PIN_LEFT_BWD)
-        right_motor = Mini_HBridge_DC_Motor_PWM(cfg.HBRIDGE_PIN_RIGHT_FWD, cfg.HBRIDGE_PIN_RIGHT_BWD)
-        two_wheel_control = TwoWheelSteeringThrottle()
+        two_wheel_control = actuator.TwoWheelSteeringThrottle()
 
         V.add(two_wheel_control,
                 inputs=['throttle', 'angle'],
@@ -589,11 +625,16 @@ def run(self, mode, recording):
         V.add(right_motor, inputs=['right_motor_speed'])
 
     elif cfg.DRIVE_TRAIN_TYPE == "DC_TWO_WHEEL_L298N":
-        from donkeycar.parts.actuator import TwoWheelSteeringThrottle, L298N_HBridge_DC_Motor
+        left_motor = actuator.L298N_HBridge_3pin(
+            pins.output_pin_by_id(cfg.HBRIDGE_L298N_PIN_LEFT_FWD), 
+            pins.output_pin_by_id(cfg.HBRIDGE_L298N_PIN_LEFT_BWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_L298N_PIN_LEFT_EN))
+        right_motor = actuator.L298N_HBridge_3pin(
+            pins.output_pin_by_id(cfg.HBRIDGE_L298N_PIN_RIGHT_FWD), 
+            pins.output_pin_by_id(cfg.HBRIDGE_L298N_PIN_RIGHT_BWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_L298N_PIN_RIGHT_EN))
 
-        left_motor = L298N_HBridge_DC_Motor(cfg.HBRIDGE_L298N_PIN_LEFT_FWD, cfg.HBRIDGE_L298N_PIN_LEFT_BWD, cfg.HBRIDGE_L298N_PIN_LEFT_EN)
-        right_motor = L298N_HBridge_DC_Motor(cfg.HBRIDGE_L298N_PIN_RIGHT_FWD, cfg.HBRIDGE_L298N_PIN_RIGHT_BWD, cfg.HBRIDGE_L298N_PIN_RIGHT_EN)
-        two_wheel_control = TwoWheelSteeringThrottle()
+        two_wheel_control = actuator.TwoWheelSteeringThrottle()
 
         V.add(two_wheel_control,
                 inputs=['throttle', 'angle'],
@@ -602,7 +643,52 @@ def run(self, mode, recording):
         V.add(left_motor, inputs=['left_motor_speed'])
         V.add(right_motor, inputs=['right_motor_speed'])
 
+    elif cfg.DRIVE_TRAIN_TYPE == "SERVO_HBRIDGE_2PIN":
+        #
+        # Servo for steering and HBridge motor driver in 2pin mode for motor
+        #
+        from donkeycar.parts.actuator import PWMSteering, PWMThrottle, PulseController
+        steering_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_STEERING_PIN),
+            pwm_scale=cfg.PWM_STEERING_SCALE, 
+            pwm_inverted=cfg.PWM_STEERING_INVERTED)
+        steering = PWMSteering(controller=steering_controller,
+                                        left_pulse=cfg.STEERING_LEFT_PWM, 
+                                        right_pulse=cfg.STEERING_RIGHT_PWM)
+
+        motor = actuator.L298N_HBridge_2pin(
+            pins.pwm_pin_by_id(cfg.HBRIDGE_2PIN_DUTY_FWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_2PIN_DUTY_BWD))
+
+        V.add(steering, inputs=['angle'], threaded=True)
+        V.add(motor, inputs=["throttle"])
+        
+    elif cfg.DRIVE_TRAIN_TYPE == "SERVO_HBRIDGE_3PIN":
+        #
+        # Servo for steering and HBridge motor driver in 3pin mode for motor
+        #
+        from donkeycar.parts.actuator import PWMSteering, PWMThrottle, PulseController
+        steering_controller = PulseController(
+            pwm_pin=pins.pwm_pin_by_id(cfg.PWM_STEERING_PIN),
+            pwm_scale=cfg.PWM_STEERING_SCALE, 
+            pwm_inverted=cfg.PWM_STEERING_INVERTED)
+        steering = PWMSteering(controller=steering_controller,
+                                        left_pulse=cfg.STEERING_LEFT_PWM, 
+                                        right_pulse=cfg.STEERING_RIGHT_PWM)
+
+        motor = actuator.L298N_HBridge_3pin(
+            pins.output_pin_by_id(cfg.HBRIDGE_3PIN_FWD), 
+            pins.output_pin_by_id(cfg.HBRIDGE_3PIN_BWD), 
+            pins.pwm_pin_by_id(cfg.HBRIDGE_3PIN_DUTY))
+
+        V.add(steering, inputs=['angle'], threaded=True)
+        V.add(motor, inputs=["throttle"])
+        
     elif cfg.DRIVE_TRAIN_TYPE == "SERVO_HBRIDGE_PWM":
+        #
+        # Thi driver is DEPRECATED in favor of 'DRIVE_TRAIN_TYPE == "SERVO_HBRIDGE_2PIN"'
+        # This driver will be removed in a future release
+        #
         from donkeycar.parts.actuator import ServoBlaster, PWMSteering
         steering_controller = ServoBlaster(cfg.STEERING_CHANNEL) #really pin
         # PWM pulse values should be in the range of 100 to 200
@@ -623,6 +709,10 @@ def run(self, mode, recording):
         V.add(RoboHATDriver(cfg), inputs=['angle', 'throttle'])
     
     elif cfg.DRIVE_TRAIN_TYPE == "PIGPIO_PWM":
+        #
+        # Thi driver is DEPRECATED in favor of 'DRIVE_TRAIN_TYPE == "PWM_STEERING_THROTTLE"'
+        # This driver will be removed in a future release
+        #
         from donkeycar.parts.actuator import PWMSteering, PWMThrottle, PiGPIO_PWM
         steering_controller = PiGPIO_PWM(cfg.STEERING_PWM_PIN, freq=cfg.STEERING_PWM_FREQ, inverted=cfg.STEERING_PWM_INVERTED)
         steering = PWMSteering(controller=steering_controller,
diff --git a/donkeycar/utilities/__init__.py b/donkeycar/utilities/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/donkeycar/utilities/deprecated.py b/donkeycar/utilities/deprecated.py
new file mode 100644
index 000000000..333c0ef5a
--- /dev/null
+++ b/donkeycar/utilities/deprecated.py
@@ -0,0 +1,80 @@
+import functools
+import inspect
+import warnings
+
+string_types = (type(b''), type(u''))
+
+
+def deprecated(reason):
+    """
+    This is a decorator which can be used to mark functions
+    or classes as deprecated. It will result in a warning
+    being emitted when the function is used.
+    Taken from this fantastic answer by Laurent LaPorte:
+    https://stackoverflow.com/a/40301488/1733315
+    """
+
+    if isinstance(reason, string_types):
+
+        # The @deprecated is used with a 'reason'.
+        #
+        # .. code-block:: python
+        #
+        #    @deprecated("please, use another function")
+        #    def old_function(x, y):
+        #      pass
+
+        def decorator(func1):
+
+            if inspect.isclass(func1):
+                fmt1 = "Call to deprecated class {name} ({reason})."
+            else:
+                fmt1 = "Call to deprecated function {name} ({reason})."
+
+            @functools.wraps(func1)
+            def new_func1(*args, **kwargs):
+                warnings.simplefilter('always', DeprecationWarning)
+                warnings.warn(
+                    fmt1.format(name=func1.__name__, reason=reason),
+                    category=DeprecationWarning,
+                    stacklevel=2
+                )
+                warnings.simplefilter('default', DeprecationWarning)
+                return func1(*args, **kwargs)
+
+            return new_func1
+
+        return decorator
+
+    elif inspect.isclass(reason) or inspect.isfunction(reason):
+
+        # The @deprecated is used without any 'reason'.
+        #
+        # .. code-block:: python
+        #
+        #    @deprecated
+        #    def old_function(x, y):
+        #      pass
+
+        func2 = reason
+
+        if inspect.isclass(func2):
+            fmt2 = "Call to deprecated class {name}."
+        else:
+            fmt2 = "Call to deprecated function {name}."
+
+        @functools.wraps(func2)
+        def new_func2(*args, **kwargs):
+            warnings.simplefilter('always', DeprecationWarning)
+            warnings.warn(
+                fmt2.format(name=func2.__name__),
+                category=DeprecationWarning,
+                stacklevel=2
+            )
+            warnings.simplefilter('default', DeprecationWarning)
+            return func2(*args, **kwargs)
+
+        return new_func2
+
+    else:
+        raise TypeError(repr(type(reason)))
diff --git a/setup.py b/setup.py
index 3827bedae..453ce20ab 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@ def package_files(directory, strip_leading):
     long_description = fh.read()
 
 setup(name='donkeycar',
-      version="4.3.2",
+      version="4.3.3",
       long_description=long_description,
       description='Self driving library for python.',
       url='https://github.com/autorope/donkeycar',
@@ -64,7 +64,8 @@ def package_files(directory, strip_leading):
               'Adafruit_PCA9685',
               'adafruit-circuitpython-lis3dh',
               'adafruit-circuitpython-ssd1306',
-              'Jetson.GPIO'
+              'Jetson.GPIO',
+              'pyserial',
           ],
           'pc': [
               'matplotlib',


Test patch:
diff --git a/donkeycar/tests/test_web_socket.py b/donkeycar/tests/test_web_socket.py
index a6eabfa04..296bae3b2 100644
--- a/donkeycar/tests/test_web_socket.py
+++ b/donkeycar/tests/test_web_socket.py
@@ -27,7 +27,7 @@ def get_ws_url(self):
         return "ws://localhost:" + str(self.get_http_port()) + "/"
 
     @tornado.testing.gen_test
-    def test_calibrate_servo_esc_1(self):
+    def test_calibrate_servo_esc_1b(self):
         ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
 
         # Now we can run a test on the WebSocket.
@@ -43,7 +43,23 @@ def test_calibrate_servo_esc_1(self):
         assert isinstance(self.app.drive_train['steering'].right_pulse, Mock)
 
     @tornado.testing.gen_test
-    def test_calibrate_servo_esc_2(self):
+    def test_calibrate_servo_esc_1a(self):
+        ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
+
+        # Now we can run a test on the WebSocket.
+        self.app.drive_train = dict()
+        self.app.drive_train['steering'] = Mock()
+        self.app.drive_train_type = "PWM_STEERING_THROTTLE"
+
+        data = {"config": {"STEERING_LEFT_PWM": 444}}
+        yield ws_client.write_message(json.dumps(data))
+        yield ws_client.close()
+
+        assert self.app.drive_train['steering'].left_pulse == 444
+        assert isinstance(self.app.drive_train['steering'].right_pulse, Mock)
+
+    @tornado.testing.gen_test
+    def test_calibrate_servo_esc_2b(self):
         ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
 
         # Now we can run a test on the WebSocket.
@@ -59,7 +75,23 @@ def test_calibrate_servo_esc_2(self):
         assert isinstance(self.app.drive_train['steering'].left_pulse, Mock)
 
     @tornado.testing.gen_test
-    def test_calibrate_servo_esc_3(self):
+    def test_calibrate_servo_esc_2a(self):
+        ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
+
+        # Now we can run a test on the WebSocket.
+        self.app.drive_train = dict()
+        self.app.drive_train['steering'] = Mock()
+        self.app.drive_train_type = "PWM_STEERING_THROTTLE"
+
+        data = {"config": {"STEERING_RIGHT_PWM": 555}}
+        yield ws_client.write_message(json.dumps(data))
+        yield ws_client.close()
+
+        assert self.app.drive_train['steering'].right_pulse == 555
+        assert isinstance(self.app.drive_train['steering'].left_pulse, Mock)
+
+    @tornado.testing.gen_test
+    def test_calibrate_servo_esc_3b(self):
         ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
 
         # Now we can run a test on the WebSocket.
@@ -74,6 +106,22 @@ def test_calibrate_servo_esc_3(self):
         assert self.app.drive_train['throttle'].max_pulse == 666
         assert isinstance(self.app.drive_train['throttle'].min_pulse, Mock)
 
+    @tornado.testing.gen_test
+    def test_calibrate_servo_esc_3a(self):
+        ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())
+
+        # Now we can run a test on the WebSocket.
+        self.app.drive_train = dict()
+        self.app.drive_train['throttle'] = Mock()
+        self.app.drive_train_type = "PWM_STEERING_THROTTLE"
+
+        data = {"config": {"THROTTLE_FORWARD_PWM": 666}}
+        yield ws_client.write_message(json.dumps(data))
+        yield ws_client.close()
+
+        assert self.app.drive_train['throttle'].max_pulse == 666
+        assert isinstance(self.app.drive_train['throttle'].min_pulse, Mock)
+
     @tornado.testing.gen_test
     def test_calibrate_mm1(self):
         ws_client = yield tornado.websocket.websocket_connect(self.get_ws_url())