A lightweight desktop application for controlling ONVIF Profile S IP cameras — pan, tilt, zoom, focus, and wiper. Built with Python and tkinter; no third-party packages required.
- Camera presets — Store multiple cameras (name, IP, port, credentials, HTTP/HTTPS); one-click selection buttons on the main window
- Virtual joystick — Drag in any direction to pan/tilt; distance from centre maps linearly to speed
- Mouse wheel zoom — Scroll anywhere on the window to zoom in/out
- Keyboard shortcuts — Arrow keys for pan/tilt, Z/X for zoom, W for wiper
- PTZ presets — 3 saved-position slots per camera; one-click Goto and Set buttons recall or overwrite positions stored on the camera
- Zoom — Continuous zoom in/out with hold-to-move, auto-stop on release
- Focus — Manual near/far adjustment; Auto/Manual mode toggle
- Wiper — Auxiliary command trigger
- Camera Settings dialog — Speed sliders (PTZ, zoom, focus) and media profile selector in a dedicated dialog
- HTTPS support — Optional TLS encryption via checkbox (requires Python with
ssl) - Persistent connections — TCP/TLS connection reused across SOAP calls; no per-call handshake overhead
- Auto-reconnect — Heartbeat detects dropped connections and reconnects automatically; status shows "Reconnecting…" in orange
- Settings persistence — Camera presets saved to
~/.onvif_gui.ini - Service discovery — Detects correct service URLs automatically via
GetCapabilities - WS-Security — PasswordDigest authentication (SOAP 1.2)
- Threaded I/O — All camera calls run off the UI thread; GUI stays responsive
- Dependency preflight — Checks Python version and tkinter at startup with install instructions if missing
- Python 3.8+ with tkinter included
- Full installer from python.org (recommended)
- Anaconda / Miniconda also works
- The
pythoncoreminimal package does not include tkinter
- No pip packages — standard library only
sslmodule for HTTPS (included in all standard Python distributions)
git clone https://github.com/MRC757/onvif-camera-gui.git
cd onvif-camera-gui
python onvif_camera_gui.pyNo virtual environment or pip install needed.
- Click Manage Cameras
- Click Add and fill in the camera's name, IP, port, username, password, and HTTP/HTTPS
- Click Save — a button labelled with the camera name appears on the main window
- Click the camera button to connect; it turns blue when connected
- Add as many cameras as needed and switch between them with a single click
- Click Camera Settings to adjust PTZ, zoom, and focus speeds, or to select a media profile
- Drag the virtual joystick to pan/tilt — farther from centre = faster movement
- Scroll the mouse wheel anywhere on the window to zoom in/out
- Use the zoom, focus, and wiper controls as needed
- Use Goto 1/2/3 to move the camera to a saved position; use Set 1/2/3 to overwrite that slot with the current position
- If the camera reboots or the network drops, the status will show Reconnecting… and the app will reconnect automatically
| Key | Action |
|---|---|
↑ |
Tilt up |
↓ |
Tilt down |
← |
Pan left |
→ |
Pan right |
Z |
Zoom in |
X |
Zoom out |
W |
Activate wiper |
| Mouse wheel | Zoom in / out |
- Diagonal movement works — hold
↑+→to pan-right and tilt-up simultaneously - Speed is scaled by the PTZ / Zoom speed sliders in Camera Settings
- Shortcuts are suppressed while a text field has keyboard focus
Most cameras maintain a separate ONVIF user list from the regular web interface accounts. Consult your camera's manual for the exact menu path — it is typically found under a section labelled ONVIF, Security, or Users.
- Open the camera web interface:
http://<camera-ip> - Navigate to the ONVIF user management section
- Create a user with Administrator or Operator role
- Use that username and password when adding the camera in Manage Cameras
WS-Security authentication requires the PC clock and camera clock to agree within 5 minutes. If you see Sender not authorized errors after entering correct credentials, sync the clocks:
- Camera: Enable NTP in the camera's date/time settings
- Windows:
Settings → Time & Language → Date & Time → Sync now
| Error | Cause | Fix |
|---|---|---|
Connection error |
Wrong IP/port, or camera unreachable | Verify network connectivity and camera address |
env:VersionMismatch |
Camera requires SOAP 1.2 but received SOAP 1.1 | Already fixed in this version |
ter:NotAuthorized |
Wrong credentials or clock skew > 5 min | Check ONVIF user credentials; sync clocks |
HTTP 404 on media/PTZ |
Camera uses non-default service URL | Already fixed via GetCapabilities discovery |
SSL handshake failed: plain HTTP |
HTTPS checked but camera endpoint is plain HTTP | Uncheck Use HTTPS or change port to the camera's HTTPS port (often 443) |
| GUI doesn't open (silent exit) | pythoncore package lacks tkinter |
Use full Python from python.org or Anaconda |
[WARNING] ssl module not available |
Python built without OpenSSL | Reinstall full Python; HTTPS checkbox will be disabled automatically |
| Status stays "Reconnecting…" | Camera offline or credentials changed | Verify camera is reachable; check credentials in Manage Cameras |
Module structure
onvif_camera_gui.py
│
├── _check_dependencies() # Preflight: Python version, tkinter, ssl
│
├── CameraPreset # Data class: name, ip, port, username, password, use_https
├── load_camera_presets() # Read [camera_N] sections from ~/.onvif_gui.ini
├── save_camera_presets() # Write [camera_N] sections, preserve other sections
│
├── ONVIFClient # Low-level SOAP 1.2 transport
│ ├── _create_security_header() # WS-Security PasswordDigest
│ ├── _create_soap_envelope() # SOAP 1.2 envelope builder
│ ├── _get_conn() # Return / create cached http.client connection
│ ├── _drop_conn() # Close and discard connection on error
│ ├── _send_request() # POST over persistent connection; retry-once on drop
│ ├── close_connections() # Close all persistent connections (on disconnect)
│ ├── get_device_information() # Verify connectivity / heartbeat ping
│ ├── get_capabilities() # Discover service URLs
│ ├── get_profiles() # Media profile tokens
│ ├── get_video_sources() # Video source tokens
│ ├── continuous_move() # PTZ ContinuousMove
│ ├── stop() # PTZ Stop
│ ├── focus_continuous() # Imaging Move
│ ├── focus_stop() # Imaging Stop
│ ├── set_focus_mode() # Imaging SetImagingSettings
│ ├── send_auxiliary_command() # PTZ SendAuxiliaryCommand (wiper)
│ ├── get_presets() # PTZ GetPresets
│ ├── goto_preset() # PTZ GotoPreset
│ └── set_preset() # PTZ SetPreset
│
├── ONVIFCameraController # High-level connection & command logic
│ ├── connect() # Auth + capability discovery + profile init
│ ├── disconnect() # Stop movement + close_connections() + clean up
│ ├── move_continuous_full() # Combined pan/tilt/zoom in one SOAP call
│ ├── goto_ptz_preset() # Move camera to stored preset slot
│ ├── save_ptz_preset() # Save current position to preset slot
│ └── get_presets() # Fetch preset list from camera
│
├── CameraSettingsDialog # Non-modal dialog: media profile selector + speed sliders
│ ├── show() # Open or raise existing dialog
│ ├── update_profiles() # Called on connect — populate profile combo
│ └── clear_profiles() # Called on disconnect — reset profile combo
│
├── ConnectionDialog # Internal connection handler (used by presets)
│ ├── _load_settings() # Read ~/.onvif_gui.ini on open
│ └── _save_settings() # Write ~/.onvif_gui.ini on successful connect
│
├── CameraEditDialog # Modal dialog: add / edit a single camera preset
├── CameraManagerDialog # Non-modal dialog: list + Add/Edit/Delete/↑/↓
│
├── Joystick (tk.Canvas) # Circular virtual joystick widget
│ ├── set_enabled() # Blue handle = connected; grey = disconnected
│ ├── _on_press/drag/release # Mouse event handlers
│ ├── _process() # Maps (x,y) → normalised (pan, tilt) in [-1, 1]
│ └── _poll() # Fires on_move_cb every 80 ms while held
│
└── CameraControlGUI # Main window + all UI panels
├── setup_cameras_frame() # Camera preset button panel (top of window)
├── _rebuild_camera_buttons() # Recreate tk.Button per preset; re-match active
├── _update_camera_button_styles() # Blue/sunken = active; default = inactive
├── _select_camera(preset) # Thread: disconnect → connect to preset
├── _bind_keyboard() # Attach KeyPress / KeyRelease / MouseWheel to root
├── _send_key_motion() # Translate held-key set → single SOAP command
├── _on_mousewheel() # Scroll-wheel zoom with debounced stop (200 ms)
├── _joystick_move() # pan/tilt × speed → move_continuous_full() in a thread
├── _joystick_stop() # Increments move-gen counter; sends stop + backup stop
├── _backup_stop() # Safety-net stop 200 ms after release
├── _schedule_heartbeat() # Ping camera every 10 s to detect drops
├── _heartbeat_tick() # Runs GetDeviceInformation off UI thread
├── _on_connection_lost() # Triggered by failed heartbeat; starts reconnect loop
└── _reconnect_tick() # Retry connect every 5 s until success
Protocol details
This tool implements SOAP 1.2 (required by most modern ONVIF cameras):
| Property | Value |
|---|---|
| Envelope namespace | http://www.w3.org/2003/05/soap-envelope |
| Content-Type | application/soap+xml; charset=utf-8; action="<uri>" |
| Authentication | WS-Security UsernameToken PasswordDigest |
| Service discovery | GetCapabilities on device service |
| HTTP transport | http.client.HTTPConnection / HTTPSConnection |
| Connection strategy | Persistent (keep-alive); one connection per host:port, reused across calls |
| Retry policy | Retry once on network/connection errors; drop and recreate socket |
SOAP 1.1 (text/xml + SOAPAction header) will be rejected by cameras that require SOAP 1.2, returning env:VersionMismatch.
Why persistent connections matter for HTTPS: Each new TCP+TLS connection costs 2–4 round trips for the handshake. Persistent http.client connections pay this cost once; subsequent SOAP calls cost only 1 RTT, matching HTTP performance.
Bug reports and pull requests are welcome. For significant changes please open an issue first to discuss the approach.
The project is intentionally kept as a single Python file with no external dependencies — please keep it that way.
This project is licensed under the MIT License.