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.
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.