In the 4th part of this series, an analog joystick is used to control the Pan-Tilt. For this purpose, the previous project structure and a few of the existing Python will be expanded (slightly). At the end of this tutorial, by making just a small change to the configuration, you can choose between Joystick or Bluetooth gamepad how the Pan-Tilt is manually controlled. As in all my previous community tutorials, I also try to provide additional information that fits the context.
Previous tutorials
- Pan-Tilt Playground: Part 1 - Build your own Pan-Tilt
- Pan-Tilt Playground: Part 2 - Servo driver creation
- Pan-Tilt Playground: Part 3 - Bluetooth gamepad controller
You will learn to control the pan-tilt using an analog joystick (ADC). In addition, you will better understand how to efficiently create and use your own Python modules and packages.
In this part only two new files are required. These files are created in the existing controller folder. These are the files called: __init__.py and joystick.py.
# change into project root directory
$ cd Pan-Tilt/
# create new file __init__.py
$ touch libs/controller/__init__.py
# create new file joystick.py
$ touch libs/controller/joystick.py
The structure of the project should now look like this:
# run tree command (optional)
$ tree .
.
|-- config
| `-- controllers.ini
|-- libs
| |-- configuration.py
| |-- controller
| | |-- __init__.py
| | |-- bluetooth.py
| | `-- joystick.py
| `-- servo.py
`-- main.py
3 directories, 7 files
The content of python files configuration.py, bluetooth.py and servo.py stays the same. So there is no need to dive deeper here. If you still want to know more about these files, read the respective previous tutorials.
controllers.ini
The content of this existing ini file is expanded to include a new section for the Joystick. The four values (x_left, x_right, y_up and y_down) determine from which ADC value the Pan-Tilt movement (X = Pan and Y = Tilt) should be carried out. So if you want a more sensitive reaction, you can adjust the values according to your needs at any time. In the example the range is from 0 to 4096 (12 bit). 2048 are the values for x and y in the center.
[bluetooth]
enabled = false
mac = e4:17:d8:12:ea:3b
name = 8BitDo Zero 2 gamepad
btn_a = 304
btn_b = 305
btn_x = 307
btn_y = 308
btn_tl = 310
btn_tr = 311
[joystick]
enabled = true
x_left = 100
x_right = 3995
y_up = 3995
y_down = 100
__init__.py
The __init__.py file is an important part of creating a Python package. In many cases this file is empty. In order to simplify the imports (in main.py), the file in this example contains some content. The modules in the controllers folder are represented here as well as the current package version.
from .bluetooth import BluetoothControllerHandler
from .joystick import JoystickControllerHandler
__version__ = "1.0"
__all__ = ["BluetoothControllerHandler", "JoystickControllerHandler"]
joystick.py
This class is very similar to the already existing Bluetooth class. There are various methods inside (one for initialization, one for acquiring the ADC values and one for returning the status as copy). It returns the same Python dictionary like we defined for Bluetooth. Once you understand the two classes (bluetooth.py and joystick.py) and what exactly is triggered by the return value in the main.py file, you can always add new controllers for the Pan-Tilt! You should quickly find your way around using the Python DocStrings and TypeHints.
from threading import Thread, Lock
from pinpong.board import ADC, Pin
from time import sleep
class JoystickControllerHandler:
"""
This class is designed to interface with analog joystick inputs, providing a mechanism to monitor
movement in both horizontal and vertical directions.
:ivar __DELAY: The delay in seconds between each joystick read.
"""
__DELAY: float = .025
def __init__(self, pin_x: Pin, pin_y: Pin, mapping: dict):
"""
Initializes a joystick control interface using two analog pins and a mapping configuration.
:param pin_x: The ADC GPIO pin associated with the horizontal direction (X-axis) of the joystick.
:type pin_x: Pin
:param pin_y: The ADC GPIO pin associated with the vertical direction (Y-axis) of the joystick.
:type pin_y: Pin
:param mapping: A dictionary defining the joystick's behavioral mapping.
:type mapping: dict
"""
self._btn_status = {'left': False, 'right': False, 'down': False, 'up': False}
self._joystick_config = mapping
self._joystick_x = ADC(Pin(pin_x))
self._joystick_y = ADC(Pin(pin_y))
self._lock = Lock()
self._thread = Thread(target=self._event_listener, daemon=True)
self._thread.start()
def _event_listener(self) -> None:
"""
Continuously monitors joystick input and updates button status.
:return: None
"""
while True:
x = self._joystick_x.read()
y = self._joystick_y.read()
cfg = self._joystick_config
self._btn_status.update({
'right': x > cfg['x_right'],
'left': x < cfg['x_left'],
'up': y > cfg['y_up'],
'down': y < cfg['y_down']
})
sleep(self.__DELAY)
def get_status(self) -> dict:
"""
Retrieves the current button status.
:return: The current button status.
:rtype: dict
"""
with self._lock:
return self._btn_status.copy()
main.py
Since the main.py file provides the actual program, this file also needs to be adjusted/extended somewhat. There is a change to the import, a pair of new constants (for the joystick pins) and a change to the logic of how the Bluetooth and joystick classes are initialized (If-condition).
Attention: You determine which controller you want to initialize in the controllers.ini file (enabled = true or false). You can activate just one controller!
Note: It would of course also be possible to only import the controller modules, from the package, if they are actually needed. Personally, I think it contradicts the code structure and can lead to confusion. But if you find it a better solution, just adapt the code.
from sys import exit
from time import sleep
from typing import Optional
from pinpong.board import Board, Pin
from libs.configuration import load_configuration
from libs.controller import BluetoothControllerHandler, JoystickControllerHandler
from libs.servo import DFServo300
PAN_PIN: int = 2
PAN_END_ANGLE: int = 180
PAN_START_ANGLE: int = PAN_END_ANGLE // 2
PAN_SERVO_STEP: int = 4
TILT_PIN: int = 3
TILT_END_ANGLE: int = 90
TILT_START_ANGLE: int = TILT_END_ANGLE // 2
TILT_SERVO_STEP: int = 2
LONG_DELAY: float = .5
SHORT_DELAY: float = .015
JOYSTICK_X_PIN: Pin = Pin.P21
JOYSTICK_Y_PIN: Pin = Pin.P22
CONFIGURATION_PATH: str = 'config/controllers.ini'
def move_servo(servo: DFServo300, nth: int, step: int, clockwise: bool = True, delay: Optional[float] = None) -> None:
"""
Moves the servo motor by a specified step in a given direction, ensuring the
angle remains within the permissible range.
:param servo: An instance of the targeted servo motor.
:type servo: DFServo300
:param nth: Maximum permissible angle for the servo's operation.
:type : int
:param step: Incremental angle to move the servo.
:type step: int
:param clockwise: Boolean indicating the direction of rotation (default: True).
:type clockwise: bool
:param delay: Optional time in seconds to wait after moving the servo (default: None).
:type delay: Optional[float]
:return: None
"""
current_angle = servo.get_angle()
new_angle = current_angle + step if clockwise else current_angle - step
if 0 <= new_angle <= nth and new_angle != current_angle:
servo.angle(new_angle)
if delay:
sleep(delay)
if __name__ == '__main__':
Board("UNIHIKER").begin()
config_data = load_configuration(path=CONFIGURATION_PATH)
enabled_controller = list(config_data.keys())[0]
if enabled_controller == "bluetooth":
button_mappings = {
key: int(value) for key, value in config_data["bluetooth"].items() if key.startswith("btn_")
}
controller = BluetoothControllerHandler(name=config_data['bluetooth']['name'],
mac=config_data['bluetooth']['mac'],
buttons=button_mappings)
elif enabled_controller == "joystick":
x_y_mappings = config_data['joystick']
controller = JoystickControllerHandler(pin_x=JOYSTICK_X_PIN,
pin_y=JOYSTICK_Y_PIN,
mapping=x_y_mappings)
else:
raise ValueError(f"Invalid controller type: {enabled_controller}")
pan = DFServo300(PAN_PIN)
tilt = DFServo300(TILT_PIN)
try:
print('[INFO] Move to initial position...')
pan.angle(PAN_START_ANGLE)
tilt.angle(TILT_START_ANGLE)
sleep(LONG_DELAY)
while True:
controller_status = controller.get_status()
if controller_status['left'] and not controller_status['right']:
move_servo(servo=pan, nth=PAN_END_ANGLE, step=PAN_SERVO_STEP)
if controller_status['right'] and not controller_status['left']:
move_servo(servo=pan, nth=PAN_END_ANGLE, clockwise=False, step=PAN_SERVO_STEP)
if controller_status['down'] and not controller_status['up']:
move_servo(servo=tilt, nth=TILT_END_ANGLE, clockwise=False, step=TILT_SERVO_STEP)
if controller_status['up'] and not controller_status['down']:
move_servo(servo=tilt, nth=TILT_END_ANGLE, step=TILT_SERVO_STEP)
sleep(SHORT_DELAY)
except KeyboardInterrupt:
print('[INFO] KeyboardInterrupt triggered...')
finally:
print('[INFO] Move to reset position...')
pan.reset()
tilt.reset()
sleep(LONG_DELAY)
exit(0)
The analog joystick consists of two potentiometers and a button in one device (in this tutorial the button function is not used). So you can also use 2 potentiometers instead of the joystick. These are available in the DFRobot shop as:
- Gravity: Analog Slide Position Potentiometer
- Gravity: Analog Rotation Potentiometer
I posted a Instagram Reel for you to see the Pan-Tilt controlled by the Joystick in action.
