A PySide6 desktop application for controlling pan-tilt-zoom (PTZ) cameras through a pluggable driver abstraction. The repository ships with three drivers — a software mock, VISCA-over-IP and ONVIF — and a built-in live RTSP preview.
- Sidebar with the registered cameras and a per-item status indicator
- Eight-direction pan/tilt pad with continuous (press-and-hold) motion
- Zoom in/out controls with a shared speed slider
- Live status panel: connection state and normalised pan/tilt/zoom
- Live preview: RTSP video when a stream URL is configured, schematic PTZ visualisation otherwise
- Six-slot preset system (save current position / recall on demand)
- Rolling event log fed by the standard library logger
- Cross-platform user-state persistence (camera list and presets)
- Three shipped drivers:
mock,visca_ip,onvif— selected at startup via theCAMERA_DRIVERenvironment variable
The application is split into four layers, each with a single responsibility.
The UI knows nothing about driver protocols, and drivers know nothing about
widgets — they meet through CameraService.
┌─────────────────────────────────────────────────────────┐
│ UI │
│ MainWindow, ControlPanel, PreviewPanel, … │
└────────────────────────────┬────────────────────────────┘
│ (Qt signals / method calls)
┌────────────────────────────▼────────────────────────────┐
│ Service │
│ CameraService — owns the active camera, presets, │
│ persistence, error fan-out │
└──────────────┬──────────────────────────┬───────────────┘
│ │
┌──────────────▼─────────┐ ┌────────────▼──────────────┐
│ Driver │ │ Storage │
│ CameraDriver (ABC) │ │ CameraStore — atomic │
│ ├─ MockCameraDriver │ │ JSON in user data dir │
│ ├─ ViscaIpDriver │ └───────────────────────────┘
│ └─ OnvifDriver │
└────────────────────────┘
CameraDriver is a QObject with the lifecycle methods every PTZ camera
needs (connect, disconnect, move, zoom, stop, go_to) and three
state-change signals (status_changed, position_changed,
error_occurred). All blocking I/O in the real drivers (UDP socket reads
for VISCA, SOAP calls for ONVIF) runs on a dedicated worker QThread,
so the GUI thread is never blocked.
A driver factory turns a Camera into a driver instance:
def make_my_driver(camera: Camera) -> CameraDriver:
return MyHardwareDriver(host=camera.host, port=camera.port)
service = CameraService(driver_factory=make_my_driver)| Driver | Module | Transport | Extra deps |
|---|---|---|---|
mock |
app.services.drivers.mock |
none (in-memory) | — |
visca_ip |
app.services.drivers.visca_ip |
UDP, port 52381 | — (stdlib only) |
onvif |
app.services.drivers.onvif_driver |
ONVIF Profile S/T | onvif-zeep |
CameraService is the only object the UI talks to. It:
- holds the camera library and per-camera preset slots,
- maintains exactly one active camera with a bound driver,
- re-emits driver signals from a stable place so widgets don't need to reconnect themselves whenever the active camera changes,
- persists state changes through
CameraStore(atomic JSON writes to the platform-appropriate user data directory).
PreviewPanel switches automatically between two modes:
- Live RTSP video when the active camera has a non-empty
rtsp_urland the driver is connected. Frames are decoded with OpenCV on a workerQThread, converted toQImageand painted in aQLabelscaled to fit while keeping aspect ratio. - Schematic preview otherwise — a synthetic viewport that reacts to the current PTZ position, useful with the mock driver or before the user has configured a stream URL.
OpenCV is a soft dependency: install via the [video] extra to enable
the live preview; the schematic mode keeps working without it.
The window composes small, focused widgets — CameraListWidget,
ControlPanel, PreviewPanel, StatusPanel, PresetPanel, LogPanel —
each of which receives the service in its constructor and reacts to its
signals. Widgets do not call drivers directly.
app/
├── core/ # config, logging, paths
├── models/ # Camera, Position, Preset, enums
├── services/
│ ├── drivers/ # CameraDriver ABC + Mock, VISCA-IP, ONVIF impls
│ ├── video/ # RTSP capture worker
│ ├── storage.py # JSON-backed CameraStore
│ └── camera_service.py
├── ui/
│ ├── widgets/ # composable widgets
│ ├── main_window.py
│ └── styles.qss
├── main.py # entry point: configures logging, picks driver, builds app
└── __main__.py # python -m app
tests/ # pytest + pytest-qt
pyproject.toml
Requires Python 3.11 or newer.
# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install the application
pip install -e . # mock driver only
pip install -e ".[realtime]" # + ONVIF and RTSP preview
pip install -e ".[dev]" # + test tooling
# Run the GUI (mock driver by default)
python -m app
# Run with a real camera
CAMERA_DRIVER=visca_ip python -m app
CAMERA_DRIVER=onvif python -m appThe application stores its state in the platform user data directory:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/camera-control-desktop/cameras.json |
| Linux | ~/.local/share/camera-control-desktop/cameras.json |
| Windows | %LOCALAPPDATA%\camera-control-desktop\cameras.json |
Logs are written next to the data directory under the platform's user log location (rotated, 1 MiB × 3 backups).
Edit the JSON state file (or extend the UI with a "Camera settings" dialog) to fill in the fields each driver needs:
{
"id": "...",
"name": "Studio A",
"host": "192.168.1.10",
"port": 52381,
"rtsp_url": "rtsp://192.168.1.10:554/stream",
"username": "admin",
"password": "admin"
}- VISCA-IP uses
host+port(default 52381). - ONVIF uses
host+port(default 80) +username/password. - RTSP preview uses
rtsp_urlindependently of the control driver.
VISCA-over-IP. The driver implements the standard 8-byte
VISCA-over-IP framing on plain UDP. Position values returned by the
camera are vendor-dependent; the driver normalises them into [-1, 1]
using configurable pan_max / tilt_max / zoom_max constants
(DEFAULT_PAN_MAX = 0x2400, etc.). Adjust these per camera if reported
positions look compressed or clipped.
ONVIF. Built on top of onvif-zeep. The driver uses
ContinuousMove / Stop for joystick motion and AbsoluteMove for
preset recall, polling GetStatus at 2 Hz. The first profile reported
by the camera is used; cameras that expose multiple profiles are easily
supported by extending _OnvifWorker.open to pick by name.
pytestThe suite covers the mock driver's state transitions, the service's
orchestration logic, and the JSON persistence round-trip. Tests use
pytest-qt to drive the Qt event loop, so a headless CI is fine —
no display is required (QT_QPA_PLATFORM=offscreen is honoured by Qt).
The VISCA-IP and ONVIF drivers are not covered by automated tests because they require a real camera (or a non-trivial mock server) at the other end of the wire.
from app.models.camera import Camera, Position
from app.models.enums import CameraStatus, Direction, ZoomDirection
from app.services.drivers.base import CameraDriver
class MyHardwareDriver(CameraDriver):
def connect(self) -> None:
self._set_status(CameraStatus.CONNECTING)
# open transport, run handshake...
self._set_status(CameraStatus.IDLE)
def disconnect(self) -> None: ...
def move(self, direction: Direction, speed: float) -> None: ...
def zoom(self, direction: ZoomDirection, speed: float) -> None: ...
def stop(self) -> None: ...
def go_to(self, position: Position) -> None: ...
def make_my_driver(camera: Camera) -> MyHardwareDriver:
return MyHardwareDriver(host=camera.host, port=camera.port)Wire it up at startup:
from app.services.camera_service import CameraService
service = CameraService(driver_factory=make_my_driver)The UI requires no changes.
MIT — see LICENSE (add your own copyright line when forking).