UP  |  HOME

PTZ Cameras

PTZ (Pan, Tilt, Zoom) cameras are little robot cameras. They seem handy in automate security systems, or remote control productions. I got a couple that were marketed for video conferencing and church productions. One is branded Chameye, and the other Tongveo (but internally Tenveo).

Some have network features which seem very similar to security cameras. Basically you can expect RTSP streams, PoE, etc.

Remote control

Mine came with infrared remotes for control, but what makes them interesting to me is the software controls.

The main control protocols seem to be:

  • Serial
    • VISCA
    • PELCO-D
    • PELCO-P
  • Network
    • Network/IP VISCA
    • ONVIF

The serial protocols seem to commonly be RS-422/RS-485 based, but also RS-232 is an option. Some devices only receive commands, others have pairs for transmitting responses.

VISCA

VISCA is a pretty simple serial protocol with an address byte, message (up to 14 bytes), and terminator byte. It seems designed around chained RS-232, but my cheap PTZ cams seem happy to do bus configuration RS-485.

For reference documentation, I found Cisco's TelePresence Camera guide to be one of the better ones. There is also Sony's VISCA Command List

I let Claude write a little control app for playing with my cameras with a joystick/controller. This is about 5 prompt iterations, and does pan, tilt, zoom plus multiple camera control. It uses pygame and python's serial libraries.

#!/usr/bin/env python3
import pygame
import serial
import time
import sys

# Initial code generated by Claude (Anthropic), ~280 lines, 2025-04-25


class VISCAController:
    """
    Controller for multiple VISCA protocol cameras using a joystick
    """
    # VISCA Command Constants
    COMMAND_CAM = 0x01
    CATEGORY_PAN_TILTER = 0x06

    # Pan/Tilt speed limits (0x01 is slowest, 0x18 is fastest)
    MIN_SPEED = 0x01
    MAX_SPEED = 0x18

    # Button definitions
    BUTTON_CAMERA_PREV = 4  # decrements camera
    BUTTON_CAMERA_NEXT = 5  # increments camera

    def __init__(self, serial_port='/dev/ttyUSB0', baud_rate=9600):
        self.serial = serial.Serial(
            port=serial_port,
            baudrate=baud_rate,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=1
        )

        if not self.serial.isOpen():
            self.serial.open()

        print(f"Connected to VISCA network via {serial_port}")

        # Current active camera (1-n)
        self.active_camera = 1

        # Track movement state to know when to send stop commands
        self.is_panning = False
        self.is_tilting = False
        self.is_zooming = False

        # Initialize pygame for joystick handling
        pygame.init()
        pygame.joystick.init()

        # Check for joystick
        if pygame.joystick.get_count() == 0:
            print("No joystick detected!")
            sys.exit(1)

        # Set up joystick
        self.joystick = pygame.joystick.Joystick(0)
        self.joystick.init()
        print(f"Initialized joystick: {self.joystick.get_name()}")
        print(f"Currently controlling camera: {self.active_camera}")

    def __del__(self):
        """Clean up resources on destruction"""
        if hasattr(self, 'serial') and self.serial.isOpen():
            self.serial.close()
        pygame.quit()

    def send_visca_command(self, data):
        """Send a command to the active VISCA camera"""
        # Build the VISCA packet
        payload = bytes(data)

        # Print the hexadecimal byte string of the command
        hex_str = ' '.join([f"{b:02X}" for b in payload])
        print(f"Sending command: {hex_str}")

        self.serial.write(payload)
        # This was 0.05, but one of my cameras didn't process some commands
        time.sleep(0.06)  # Small delay to ensure command processing
        line = self.serial.readline()
        hex_line = ' '.join([f"{b:02X}" for b in line])
        print(f"Received back: {hex_line}")

    def select_camera(self, camera_num):
        """
        VISCA protocol can address up to 7 cameras directly (1-7),
        but with daisy-chaining and network configurations, more can be controlled.
        """
        if 1 <= camera_num <= 7:  # Maximum possible VISCA address
            self.active_camera = camera_num
            print(f"Now controlling camera {camera_num}")
            return True
        else:
            print(f"Invalid camera number: {camera_num}. Must be 1-7.")
            return False

    def next_camera(self):
        """Switch to the next camera"""
        next_cam = (self.active_camera % 7) + 1
        self.select_camera(next_cam)

    def prev_camera(self):
        """Switch to the previous camera"""
        prev_cam = self.active_camera - 1
        if prev_cam < 1:
            prev_cam = 7
        self.select_camera(prev_cam)

    def pan_tilt(self, pan_speed, tilt_speed, pan_direction, tilt_direction):
        """Send a pan/tilt command

        Parameters:
        pan_speed: 0x01 (slow) to 0x18 (fast)
        tilt_speed: 0x01 (slow) to 0x18 (fast)
        pan_direction: 0x01 (left), 0x02 (right), 0x03 (stop)
        tilt_direction: 0x01 (up), 0x02 (down), 0x03 (stop)
        """
        # Clamp speeds to valid ranges
        pan_speed = max(min(pan_speed, self.MAX_SPEED), self.MIN_SPEED)
        tilt_speed = max(min(tilt_speed, self.MAX_SPEED), self.MIN_SPEED)

        # VISCA header (address + command)
        header = [0x80 + self.active_camera, self.COMMAND_CAM, self.CATEGORY_PAN_TILTER]

        # Pan/tilt command
        command = [0x01, pan_speed, tilt_speed, pan_direction, tilt_direction]

        # Complete command (add termination)
        full_command = header + command + [0xFF]

        self.send_visca_command(full_command)

        # Update movement state
        self.is_panning = (pan_direction != 0x03)
        self.is_tilting = (tilt_direction != 0x03)

    def stop_pan_tilt(self):
        """Send a stop command for pan/tilt movements"""
        self.pan_tilt(self.MIN_SPEED, self.MIN_SPEED, 0x03, 0x03)

    def zoom(self, zoom_direction, zoom_speed=0x07):
        """Control camera zoom

        Parameters:
        zoom_direction: 0x00 (stop), 0x02 (tele/zoom in), 0x03 (wide/zoom out)
        zoom_speed: 0x00 (slow) to 0x07 (fast)
        """
        # Clamp zoom speed
        zoom_speed = max(min(zoom_speed, 0x07), 0x00)

        # Combine direction and speed into one byte
        zoom_val = (zoom_speed & 0x07) | (zoom_direction << 4)

        # VISCA command for zoom
        command = [0x80 + self.active_camera, self.COMMAND_CAM, 0x04, 0x07, zoom_val, 0xFF]

        self.send_visca_command(command)

        # Update zoom state
        self.is_zooming = (zoom_direction != 0x00)

    def stop_zoom(self):
        """Send a stop command for zoom movement"""
        self.zoom(0x00, 0x00)

    def focus(self, focus_direction):
        """Control camera focus

        Parameters:
        focus_direction: 0x00 (stop), 0x02 (far), 0x03 (near)
        """
        # VISCA command for focus
        command = [0x80 + self.active_camera, self.COMMAND_CAM, 0x04, 0x08, focus_direction, 0xFF]

        self.send_visca_command(command)

    def auto_focus(self, mode):
        """Set auto focus mode

        Parameters:
        mode: True for auto focus, False for manual focus
        """
        if mode:
            # Auto focus on
            command = [0x80 + self.active_camera, self.COMMAND_CAM, 0x04, 0x38, 0x02, 0xFF]
        else:
            # Manual focus
            command = [0x80 + self.active_camera, self.COMMAND_CAM, 0x04, 0x38, 0x03, 0xFF]

        self.send_visca_command(command)

    def reset_camera(self):
        """Reset camera to home position"""
        command = [0x80 + self.active_camera, self.COMMAND_CAM, 0x04, 0x70, 0xFF]
        self.send_visca_command(command)

    def process_joystick(self):
        """Process joystick input and convert to camera commands"""
        pygame.event.pump()  # Process event queue

        # Get joystick positions
        x_axis = self.joystick.get_axis(0)  # Left/Right (-1 to 1)
        y_axis = self.joystick.get_axis(1)  # Up/Down (-1 to 1)

        # Check for camera selection buttons
        if self.joystick.get_numbuttons() > self.BUTTON_CAMERA_PREV:
            if self.joystick.get_button(self.BUTTON_CAMERA_PREV):
                self.prev_camera()
                time.sleep(0.2)  # Debounce

        if self.joystick.get_numbuttons() > self.BUTTON_CAMERA_NEXT:
            if self.joystick.get_button(self.BUTTON_CAMERA_NEXT):
                self.next_camera()
                time.sleep(0.2)  # Debounce

        # Process Pan/Tilt from left analog stick
        if abs(x_axis) > 0.1 or abs(y_axis) > 0.1:
            # Determine pan direction
            if x_axis < -0.1:
                pan_direction = 0x01  # Left
            elif x_axis > 0.1:
                pan_direction = 0x02  # Right
            else:
                pan_direction = 0x03  # Stop pan

            # Determine tilt direction
            if y_axis < -0.1:
                tilt_direction = 0x02  # Down
            elif y_axis > 0.1:
                tilt_direction = 0x01  # Up
            else:
                tilt_direction = 0x03  # Stop tilt

            # Calculate speed based on how far the stick is pushed
            pan_speed = int(min(self.MAX_SPEED, abs(x_axis) * self.MAX_SPEED))
            tilt_speed = int(min(self.MAX_SPEED, abs(y_axis) * self.MAX_SPEED))

            # Ensure minimum speed if stick is moved
            if pan_speed < self.MIN_SPEED and abs(x_axis) > 0.1:
                pan_speed = self.MIN_SPEED
            if tilt_speed < self.MIN_SPEED and abs(y_axis) > 0.1:
                tilt_speed = self.MIN_SPEED

            self.pan_tilt(pan_speed, tilt_speed, pan_direction, tilt_direction)
        elif self.is_panning or self.is_tilting:
            # No joystick input, send stop command if previously moving
            self.stop_pan_tilt()

        # Process zoom with right analog stick or trigger buttons
        if self.joystick.get_numaxes() > 3:
            zoom_axis = self.joystick.get_axis(3)
            if zoom_axis < -0.2:
                # Zoom in
                zoom_speed = int(abs(zoom_axis) * 7)
                self.zoom(0x02, max(1, zoom_speed))
            elif zoom_axis > 0.2:
                # Zoom out
                zoom_speed = int(abs(zoom_axis) * 7)
                self.zoom(0x03, max(1, zoom_speed))
            elif self.is_zooming:
                # No zoom input, send stop command if previously zooming
                self.stop_zoom()


def main():
    try:
        # Initialize controller (update serial port as needed)
        controller = VISCAController(serial_port='/dev/ttyUSB0')

        print("VISCA Multi-Camera Joystick Controller")
        print("--------------------------------------")
        print("Use joystick to control camera movements")
        print("Button 4: Previous camera")
        print("Button 5: Next camera")
        print("Press Ctrl+C to exit")

        # Main control loop
        while True:
            controller.process_joystick()
            time.sleep(0.05)  # Small delay to prevent CPU hogging

    except KeyboardInterrupt:
        print("\nExiting controller")
    except Exception as e:
        print(f"Error: {e}")


if __name__ == "__main__":
    main()

While using a joystick or a controller seems cool I was also considering having a play with spacenavd and a spacemouse.

I got a hold of a RS-422​/RS-485 adapter with all 4 lines. I tried experimenting with the Chameye since it has 4 lines as well. When hooking up RS-422 it is like serial, transmit goes to receive and vice versa. The responses I got back weren't in line with the responses documented in the Cisco guide. Also my lazy attempt caused the camera to lag while waiting for a whole line of serial response.

Here's a few lines just for future reference:

Sending command: 81 01 06 01 01 0B 03 02 FF
Received back: D3 05 00 D3 15 00
Sending command: 81 01 06 01 01 01 03 03 FF
Received back: D3 05 00 D3 15 00
Sending command: 81 01 04 07 21 FF
Received back: D3 05 00 D3 15 00
Sending command: 81 01 04 07 00 FF
Received back: D3 05 00 D3 15 00

PELCO

After getting a better idea of things I decided I don't care for the VISCA protocol. PELCO seems to be better thought out with the ability to transmit multiple standard movement commands in the same message. I like the fixed message size, checksumming, and that it has more formal documentation.

The PELCO-P protocol seems obsolete. I don't think there's any reason to use it unless you have legacy equipment. Supports fewer cameras, and lacks commands for certain features compared to D.

For PELCO-D the protocol is described in: Pelco D Protocol.

  • TODO try it out

IP Protocols

ONVIF seems popular on the security camera devices. I haven't tried it.

IP VISCA seems to be the same VISCA commands with the address byte ignored, instead relying on IP for addressing and transport. I haven't tried it either.