├── .gitignore ├── LICENSE ├── README.md ├── docs ├── caching_camera.rst ├── camera.rst ├── conf.py ├── index.rst └── tutorial.rst ├── setup.py └── visca_over_ip ├── __init__.py ├── caching_camera.py ├── camera.py └── exceptions.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .DS_Store 131 | .vscode/settings.json 132 | 133 | docs/html -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VISCA-IP-Controller 2 | 3 | Python code for controlling PTZ cameras using VISCA commands over a local network. 4 | 5 | `pip install visca-over-ip` 6 | 7 | [Documentation](https://visca-over-ip.readthedocs.io) 8 | 9 | ## Applications 10 | 11 | If you are looking for a graphical program to control a camera, use [VISCA IP Controller GUI](https://github.com/misterhay/VISCA-IP-Controller-GUI). For joystick control use [Visca Joystick](https://github.com/International-Anglican-Church/visca-joystick). 12 | -------------------------------------------------------------------------------- /docs/caching_camera.rst: -------------------------------------------------------------------------------- 1 | :py:class:`CachingCamera` 2 | ========================= 3 | 4 | The :py:class:`visca_over_ip.CachingCamera` class is a subclass of the :py:class:`Camera` class (see :doc:`camera`). 5 | :py:class:`CachingCamera` is designed to reduce superfluous network traffic in real-time applications. 6 | **CachingCamera has exactly the same API as Camera**. 7 | There are some important drawbacks to using this class however, and it only improves the performance of a few :py:class:`Camera` methods. 8 | 9 | An example application 10 | ---------------------- 11 | 12 | Let's say you want to bind the zoom speed of a camera to the position of a joystick. 13 | To accomplish that, you might write code something like this:: 14 | 15 | from visca_over_ip import Camera 16 | 17 | cam = Camera('192.168.0.123') 18 | 19 | def main_loop(): 20 | zoom_speed = get_joystick_zoom_axis() # imaginary method to read the value of a joystick axis 21 | cam.zoom(zoom_speed) 22 | 23 | Whenever your user is not moving the joystick (which will be most of the time), 24 | the camera is going to be bombarded with instructions to *not zoom* which is kind of a waste. 25 | If we substitute :py:class:`CachingCamera` for :py:class:`Camera` in the above example, 26 | the class will be smart enough to tell the camera to stop zooming just once. 27 | Subsequent ``cam.zoom(0)`` calls will not send a message to the camera. 28 | As soon as the user moves the joystick, communications to the camera will resume. 29 | 30 | Methods with caching behavior 31 | ----------------------------- 32 | 33 | :py:meth:`CachingCamera.get_focus_mode` 34 | 35 | :py:meth:`CachingCamera.set_focus_mode` 36 | 37 | :py:meth:`CachingCamera.pantilt` 38 | 39 | :py:meth:`CachingCamera.zoom` 40 | 41 | .. _caching_drawbacks: 42 | 43 | Drawbacks 44 | --------- 45 | 46 | This class depends on having exclusive control over a camera. 47 | That means that if you have some other software or two instances of the same software interfacing with a camera, 48 | :py:class:`CachingCamera` may cause unexpected behavior. 49 | 50 | CachingCamera also only has benefits if methods are called multiple times with the same parameters. 51 | In many applications, this doesn't happen. 52 | -------------------------------------------------------------------------------- /docs/camera.rst: -------------------------------------------------------------------------------- 1 | :py:class:`Camera` Reference 2 | ============================ 3 | 4 | .. note:: 5 | The :py:class:`Camera` class has more public methods than are documented here. 6 | You're free to use these methods, although you should consider them a work-in-progress. 7 | 8 | .. autoclass:: visca_over_ip.Camera 9 | :members: __init__, close_connection, 10 | set_power, pantilt, pantilt_home, pantilt_reset, get_pantilt_position, 11 | zoom, zoom_to, get_zoom_position, 12 | increase_exposure_compensation, decrease_exposure_compensation, set_focus_mode, get_focus_mode, manual_focus, 13 | save_preset, recall_preset 14 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.append('..') 4 | 5 | import visca_over_ip 6 | 7 | project = 'visca_over_ip' 8 | author = 'Yook74 and misterhay' 9 | version = visca_over_ip.__version__ 10 | 11 | extensions = ['sphinx.ext.autodoc'] 12 | html_theme = 'nature' 13 | html_sidebars = {'**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html']} 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ``visca_over_ip`` 2 | ================= 3 | 4 | ``visca_over_ip`` is a Python driver package for Sony PTZ cameras which use the VISCA over IP protocol. 5 | See :doc:`tutorial` to get started. 6 | 7 | .. note:: 8 | This package does not allow you to capture video or stills from the camera. 9 | It only allows you to control things like pan, tilt, zoom, and focus. 10 | 11 | `Github `_ 12 | 13 | `PyPI `_ 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | tutorial 19 | camera 20 | caching_camera 21 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | Installation 5 | ------------ 6 | 7 | Run a command something like this to download and install the package from PyPI: 8 | 9 | ``pip install visca_over_ip`` 10 | 11 | 12 | Package Contents 13 | ---------------- 14 | 15 | ``visca_over_ip`` exports two main classes that you'll interact with. 16 | They are :py:class:`visca_over_ip.Camera` which allows for control and querying of a camera with VISCA over IP, 17 | and its subclass :py:class:`visca_over_ip.CachingCamera` which offers some performance improvements with a few :ref:`drawbacks`. 18 | 19 | You can skip straight to the :doc:`camera` if you like API docs, or you can read the rest of this tutorial for some example usage. 20 | 21 | Simple Usage 22 | ------------- 23 | 24 | In this example we will cause the camera to pan left and right at half speed as if it was shaking its head:: 25 | 26 | import time 27 | from visca_over_ip import Camera 28 | 29 | cam = Camera('192.168.0.123') # Your camera's IP address or hostname here 30 | 31 | while True: 32 | cam.pantilt(pan_speed=-12, tilt_speed=0) 33 | time.sleep(1) # wait one second 34 | cam.pantilt(pan_speed=12, tilt_speed=0) 35 | 36 | 37 | 38 | Multiple Cameras 39 | ---------------- 40 | 41 | In this example, we will recall the first preset on one camera and the third preset on another camera:: 42 | 43 | from_visca_over_ip import Camera 44 | 45 | cam = Camera('192.168.0.101') 46 | cam.recall_preset(0) 47 | cam.close_connection() # Important when switching between cameras 48 | 49 | cam = Camera('192.168.0.102') 50 | cam.recall_preset(2) 51 | cam.close_connection() # less important here, but doesn't hurt 52 | 53 | .. note:: 54 | It is not possible to simultaneously control two cameras which use the same port. 55 | If you desire simultaneous control of multiple cameras without switching as in the above example, 56 | you will need to set up your cameras to use different ports. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import visca_over_ip 3 | 4 | with open('README.md', 'r') as handle: 5 | long_description = handle.read() 6 | 7 | setup( 8 | name='visca_over_ip', 9 | version=visca_over_ip.__version__, 10 | description='A driver package for the VISCA over IP protocol used by some Sony PTZ cameras', 11 | long_description=long_description, 12 | long_description_content_type='text/markdown', 13 | author='Yook74 and misterhay', 14 | author_email='misterhay@gmail.com', 15 | url='https://github.com/misterhay/VISCA-IP-Controller', 16 | packages=['visca_over_ip'], 17 | ) 18 | -------------------------------------------------------------------------------- /visca_over_ip/__init__.py: -------------------------------------------------------------------------------- 1 | from visca_over_ip.camera import Camera 2 | from visca_over_ip.caching_camera import CachingCamera 3 | 4 | __version__ = '0.5.1' 5 | -------------------------------------------------------------------------------- /visca_over_ip/caching_camera.py: -------------------------------------------------------------------------------- 1 | from visca_over_ip.camera import Camera 2 | 3 | 4 | class CachingCamera(Camera): 5 | """Uses caching to improve performance and decrease network traffic. 6 | Will quickly break if multiple controllers are connected to a given camera. 7 | """ 8 | def __init__(self, ip, port=52381): 9 | super().__init__(ip, port) 10 | 11 | self.state = { 12 | 'focus_mode': super().get_focus_mode(), 13 | 'pan_tilt_stop': False, 14 | 'zoom_stop': False 15 | } 16 | 17 | def get_focus_mode(self) -> str: 18 | return self.state['focus_mode'] 19 | 20 | def set_focus_mode(self, mode: str): 21 | super().set_focus_mode(mode) 22 | self.state['focus_mode'] = mode 23 | 24 | def pantilt(self, pan_speed: int, tilt_speed: int, pan_position=None, tilt_position=None, relative=False): 25 | if pan_speed == 0 and tilt_speed == 0: 26 | if self.state['pan_tilt_stop'] is False: 27 | super().pantilt(pan_speed, tilt_speed, pan_position, tilt_position, relative) 28 | 29 | self.state['pan_tilt_stop'] = True 30 | 31 | else: 32 | super().pantilt(pan_speed, tilt_speed, pan_position, tilt_position, relative) 33 | self.state['pan_tilt_stop'] = False 34 | 35 | def zoom(self, speed: int): 36 | if speed == 0: 37 | if self.state['zoom_stop'] is False: 38 | super().zoom(speed) 39 | 40 | self.state['zoom_stop'] = True 41 | 42 | else: 43 | super().zoom(speed) 44 | self.state['zoom_stop'] = False 45 | -------------------------------------------------------------------------------- /visca_over_ip/camera.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from typing import Optional, Tuple 3 | 4 | from visca_over_ip.exceptions import ViscaException, NoQueryResponse 5 | 6 | SEQUENCE_NUM_MAX = 2 ** 32 - 1 7 | 8 | 9 | class Camera: 10 | """ 11 | Represents a camera that has a VISCA-over-IP interface. 12 | Provides methods to control a camera over that interface. 13 | 14 | Only one camera can be connected on a given port at a time. 15 | If you wish to use multiple cameras, you will need to switch between them (use :meth:`close_connection`) 16 | or set them up to use different ports. 17 | """ 18 | def __init__(self, ip: str, port=52381): 19 | """:param ip: the IP address or hostname of the camera you want to talk to. 20 | :param port: the port number to use. 52381 is the default for most cameras. 21 | """ 22 | self._location = (ip, port) 23 | self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # for UDP stuff 24 | self._sock.bind(('', 0)) 25 | self._port = self._sock.getsockname()[1] 26 | self._sock.settimeout(0.1) 27 | 28 | self.num_missed_responses = 0 29 | self.sequence_number = 0 # This number is encoded in each message and incremented after sending each message 30 | self.num_retries = 5 31 | self.reset_sequence_number() 32 | try: 33 | self._send_command('00 01') # clear the camera's interface socket 34 | except ViscaException as exc: 35 | print(f"Could not clear the camera's interface socket: {exc}") 36 | 37 | def _send_command(self, command_hex: str, query=False) -> Optional[bytes]: 38 | """Constructs a message based ong the given payload, sends it to the camera, 39 | and blocks until an acknowledge or completion response has been received. 40 | :param command_hex: The body of the command as a hex string. For example: "00 02" to power on. 41 | :param query: Set to True if this is a query and not a standard command. 42 | This affects the message preamble and also ensures that a response will be returned and not None 43 | :return: The body of the first response to the given command as bytes 44 | """ 45 | payload_type = b'\x01\x00' 46 | preamble = b'\x81' + (b'\x09' if query else b'\x01') 47 | terminator = b'\xff' 48 | 49 | payload_bytes = preamble + bytearray.fromhex(command_hex) + terminator 50 | payload_length = len(payload_bytes).to_bytes(2, 'big') 51 | 52 | exception = None 53 | for retry_num in range(self.num_retries): 54 | self._increment_sequence_number() 55 | sequence_bytes = self.sequence_number.to_bytes(4, 'big') 56 | message = payload_type + payload_length + sequence_bytes + payload_bytes 57 | 58 | self._sock.sendto(message, self._location) 59 | 60 | try: 61 | response = self._receive_response() 62 | except ViscaException as exc: 63 | exception = exc 64 | else: 65 | if response is not None: 66 | return response[1:-1] 67 | elif not query: 68 | return None 69 | if exception: 70 | raise exception 71 | else: 72 | raise NoQueryResponse(f'Could not get a response after {self.num_retries} tries') 73 | 74 | def _receive_response(self) -> Optional[bytes]: 75 | """Attempts to receive the response of the most recent command. 76 | Sometimes we don't get the response because this is UDP. 77 | In that case we just increment num_missed_responses and move on. 78 | :raises ViscaException: if the response is an error and not an acknowledge or completion 79 | """ 80 | while True: 81 | try: 82 | response = self._sock.recv(32) 83 | response_sequence_number = int.from_bytes(response[4:8], 'big') 84 | 85 | if response_sequence_number < self.sequence_number: 86 | continue 87 | else: 88 | response_payload = response[8:] 89 | if len(response_payload) > 2: 90 | status_byte = response_payload[1] 91 | if status_byte >> 4 not in [5, 4]: 92 | raise ViscaException(response_payload) 93 | else: 94 | return response_payload 95 | 96 | except socket.timeout: # Occasionally we don't get a response because this is UDP 97 | self.num_missed_responses += 1 98 | break 99 | 100 | def reset_sequence_number(self): 101 | message = bytearray.fromhex('02 00 00 01 00 00 00 01 01') 102 | self._sock.sendto(message, self._location) 103 | self._receive_response() 104 | self.sequence_number = 1 105 | 106 | def _increment_sequence_number(self): 107 | self.sequence_number += 1 108 | if self.sequence_number > SEQUENCE_NUM_MAX: 109 | self.sequence_number = 0 110 | 111 | def close_connection(self): 112 | """Only one camera can be bound to a socket at once. 113 | If you want to connect to another camera which uses the same communication port, 114 | first call this method on the first camera. 115 | """ 116 | self._sock.close() 117 | 118 | def set_power(self, power_state: bool): 119 | """Powers on or off the camera based on the value of power_state""" 120 | for _ in range(4): 121 | try: 122 | if power_state: 123 | self._send_command('04 00 02') 124 | else: 125 | self._send_command('04 00 03') 126 | 127 | except ViscaException as exc: 128 | if exc.status_code != 0x41: 129 | raise exc 130 | 131 | def info_display(self, display_mode: bool): 132 | """Sets the information display mode of the camera 133 | :param display_mode: True for on, False for off 134 | """ 135 | if display_mode: 136 | self._send_command('7E 08 18 02') 137 | else: 138 | self._send_command('7E 08 18 03') 139 | 140 | def pantilt(self, pan_speed: int, tilt_speed: int, pan_position=None, tilt_position=None, relative=False): 141 | """Commands the camera to pan and/or tilt. 142 | You must specify both pan_position and tilt_position OR specify neither 143 | 144 | :param pan_speed: -24 to 24 where negative numbers cause a right pan, 0 causes panning to stop, 145 | and positive numbers cause a left pan 146 | :param tilt_speed: -24 to 24 where negative numbers cause a downward tilt, 0 causes tilting to stop, 147 | and positive numbers cause an upward tilt. 148 | :param pan_position: if specified, the camera will move this distance or go to this absolute position 149 | depending on the value of `relative`. 150 | Should be a signed integer where 0 is the center of the range. 151 | The camera will stop panning when numbers reach a high enough magnitude, but will not wrap around. 152 | The pan limits are different for different models of camera and can be tightened by the user. 153 | :param tilt_position: if specified, the camera will move this distance or go to this absolute position 154 | depending on the value of `relative`. 155 | The camera will stop tilting when number reach a high enough magnitude, but will not wrap around. 156 | The tilt limits are different for different models of camera and can be tightened by the user. 157 | :param relative: If set to True, the position will be relative instead of absolute. 158 | 159 | :raises ViscaException: if invalid values are specified for positions 160 | :raises ValueError: if invalid values are specified for speeds 161 | """ 162 | speed_params = [pan_speed, tilt_speed] 163 | position_params = [pan_position, tilt_position] 164 | if position_params.count(None) == 1: 165 | raise ValueError('You must specify both pan_position and tilt_position or nether') 166 | 167 | if abs(pan_speed) > 24 or abs(tilt_speed) > 24: 168 | raise ValueError('pan_speed and tilt_speed must be between -24 and 24 inclusive') 169 | 170 | if not all(isinstance(param, int) or param is None for param in speed_params + position_params): 171 | raise ValueError('All parameters must be integers or None') 172 | 173 | pan_speed_hex = f'{abs(pan_speed):02x}' 174 | tilt_speed_hex = f'{abs(tilt_speed):02x}' 175 | 176 | if None not in position_params: 177 | def encode(position: int): 178 | """Converts a signed integer to hex with each nibble seperated by a 0""" 179 | pos_hex = position.to_bytes(2, 'big', signed=True).hex() 180 | return ' '.join(['0' + char for char in pos_hex]) 181 | 182 | relative_hex = '03' if relative else '02' 183 | 184 | self._send_command( 185 | '06' + relative_hex + pan_speed_hex + tilt_speed_hex + encode(pan_position) + encode(tilt_position) 186 | ) 187 | 188 | else: 189 | payload_start = '06 01' 190 | 191 | def get_direction_hex(speed: int): 192 | if speed < 0: 193 | return '01' 194 | if speed > 0: 195 | return '02' 196 | else: 197 | return '03' 198 | 199 | self._send_command( 200 | payload_start + pan_speed_hex + tilt_speed_hex + 201 | get_direction_hex(pan_speed) + get_direction_hex(tilt_speed) 202 | ) 203 | 204 | def pantilt_home(self): 205 | """Moves the camera to the home position""" 206 | self._send_command('06 04') 207 | 208 | def pantilt_reset(self): 209 | """Moves the camera to the reset position""" 210 | self._send_command('06 05') 211 | 212 | def zoom(self, speed: int): 213 | """Zooms out or in at the given speed. 214 | 215 | :param speed: -7 to 7 where positive numbers zoom in, zero stops the zooming, and negative numbers zoom out. 216 | """ 217 | if not isinstance(speed, int) or abs(speed) > 7: 218 | raise ValueError('The zoom speed must be an integer from -7 to 7 inclusive') 219 | 220 | speed_hex = f'{abs(speed):x}' 221 | 222 | if speed == 0: 223 | direction_hex = '0' 224 | elif speed > 0: 225 | direction_hex = '2' 226 | else: 227 | direction_hex = '3' 228 | 229 | self._send_command(f'04 07 {direction_hex}{speed_hex}') 230 | 231 | def zoom_to(self, position: float): 232 | """Zooms to an absolute position 233 | 234 | :param position: 0-1, where 1 is zoomed all the way in 235 | """ 236 | position_int = round(position * 16384) 237 | position_hex = f'{position_int:04x}' 238 | self._send_command('04 47 ' + ''.join(['0' + char for char in position_hex])) 239 | 240 | def digital_zoom(self, digital_zoom_state: bool): 241 | """Sets the digital zoom state of the camera 242 | :param digital_zoom_state: True for on, False for off 243 | """ 244 | if digital_zoom_state: 245 | self._send_command('04 06 02') 246 | else: 247 | self._send_command('04 06 03') 248 | 249 | def increase_exposure_compensation(self): 250 | self._send_command('04 0E 02') 251 | 252 | def decrease_exposure_compensation(self): 253 | self._send_command('04 0E 03') 254 | 255 | def set_focus_mode(self, mode: str): 256 | """Sets the focus mode of the camera 257 | 258 | :param mode: One of "auto", "manual", "auto/manual", "one push trigger", or "infinity". 259 | See the manual for an explanation of these modes. 260 | """ 261 | modes = { 262 | 'auto': '38 02', 263 | 'manual': '38 03', 264 | 'auto/manual': '38 10', 265 | 'one push trigger': '18 01', 266 | 'infinity': '18 02' 267 | } 268 | 269 | mode = mode.lower() 270 | if mode not in modes: 271 | raise ValueError(f'"{mode}" is not a valid mode. Valid modes: {", ".join(modes.keys())}') 272 | 273 | self._send_command('04 ' + modes[mode]) 274 | 275 | def set_autofocus_mode(self, mode: str): 276 | """Sets the autofocus mode of the camera 277 | :param mode: One of "normal", "interval", or "one push trigger". 278 | See the manual for an explanation of these modes. 279 | """ 280 | modes = { 281 | 'normal': '0', 282 | 'interval': '1', 283 | 'zoom trigger': '2' 284 | } 285 | 286 | mode = mode.lower() 287 | if mode not in modes: 288 | raise ValueError(f'"{mode}" is not a valid mode. Valid modes: {", ".join(modes.keys())}') 289 | 290 | self._send_command('04 57 0' + modes[mode]) 291 | 292 | def set_autofocus_interval(self, active_time: int, interval_time: int): 293 | """Sets the autofocus interval of the camera 294 | :param active_time in seconds, interval_time in seconds. 295 | """ 296 | if interval_time < 1 or interval_time > 255 or active_time < 1 or active_time > 255: 297 | raise ValueError('The time must be between 1 and 255 seconds') 298 | 299 | self._send_command('04 27 ' + f'{active_time:02x}' +' '+ f'{interval_time:02x}') 300 | 301 | def autofocus_sensitivity_low(self, sensitivity_low: bool): 302 | """Sets the sensitivity of the autofocus to low 303 | :param sensitivity_low: True for on, False for off 304 | """ 305 | if sensitivity_low: 306 | self._send_command('04 58 03') 307 | else: 308 | self._send_command('04 58 02') 309 | 310 | def manual_focus(self, speed: int): 311 | """Focuses near or far at the given speed. 312 | Set the focus mode to manual before calling this method. 313 | 314 | :param speed: -7 to 7 where positive integers focus near and negative integers focus far 315 | """ 316 | if not isinstance(speed, int) or abs(speed) > 7: 317 | raise ValueError('The focus speed must be an integer from -7 to 7 inclusive') 318 | 319 | speed_hex = f'{abs(speed):x}' 320 | 321 | if speed == 0: 322 | direction_hex = '0' 323 | elif speed > 0: 324 | direction_hex = '3' 325 | else: 326 | direction_hex = '2' 327 | 328 | self._send_command(f'04 08 {direction_hex}{speed_hex}') 329 | 330 | def ir_correction(self, mode: bool): 331 | """Sets the focus IR correction mode of the camera 332 | :param value: True for IR correction mode, False for standard mode 333 | """ 334 | if mode: 335 | self._send_command('04 11 01') 336 | else: 337 | self._send_command('04 11 00') 338 | 339 | def white_balance_mode(self, mode: str): 340 | """Sets the white balance mode of the camera 341 | :param mode: One of "auto", "indoor", "outdoor", "auto tracing", "manual", "color temperature", "one push", or "one push trigger". 342 | See the manual for an explanation of these modes. 343 | """ 344 | modes = { 345 | 'auto': '35 00', 346 | 'indoor': '35 01', 347 | 'outdoor': '35 02', 348 | 'one push': '35 03', 349 | 'auto tracing': '35 04', 350 | 'manual': '35 05', 351 | 'color temperature': '35 20', 352 | 'one push trigger': '10 05' 353 | } 354 | 355 | mode = mode.lower() 356 | if mode not in modes: 357 | raise ValueError(f'"{mode}" is not a valid mode. Valid modes: {", ".join(modes.keys())}') 358 | 359 | self._send_command('04 ' + modes[mode]) 360 | 361 | def set_red_gain(self, gain: int): 362 | """Sets the red gain of the camera 363 | :param gain: 0-255 364 | """ 365 | if not isinstance(gain, int) or gain < 0 or gain > 255: 366 | raise ValueError('The gain must be an integer from 0 to 255 inclusive') 367 | 368 | self._send_command('04 43 00 00 ' + f'{gain:02x}') 369 | 370 | def increase_red_gain(self): 371 | self._send_command('04 03 02') 372 | 373 | def decrease_red_gain(self): 374 | self._send_command('04 03 03') 375 | 376 | def reset_red_gain(self): 377 | self._send_command('04 03 00') 378 | 379 | def set_blue_gain(self, gain: int): 380 | """Sets the blue gain of the camera 381 | :param gain: 0-255 382 | """ 383 | if not isinstance(gain, int) or gain < 0 or gain > 255: 384 | raise ValueError('The gain must be an integer from 0 to 255 inclusive') 385 | 386 | self._send_command('04 44 00 00 ' + f'{gain:02x}') 387 | 388 | def increase_blue_gain(self): 389 | self._send_command('04 04 02') 390 | 391 | def decrease_blue_gain(self): 392 | self._send_command('04 03 03') 393 | 394 | def reset_blue_gain(self): 395 | self._send_command('04 04 00') 396 | 397 | def set_white_balance_temperature(self, temperature: int): 398 | """Sets the white balance temperature of the camera 399 | :param temperature: 0-255 400 | """ 401 | if not isinstance(temperature, int) or temperature < 0 or temperature > 255: 402 | raise ValueError('The temperature must be an integer from 0 to 255 inclusive') 403 | 404 | self._send_command('04 43 00 20 ' + f'{temperature:02x}') 405 | 406 | def increase_white_balance_temperature(self): 407 | self._send_command('04 03 02') 408 | 409 | def decrease_white_balance_temperature(self): 410 | self._send_command('04 03 03') 411 | 412 | def reset_white_balance_temperature(self): 413 | self._send_command('04 03 00') 414 | 415 | def set_color_gain(self, color:str, gain: int): 416 | """Sets the color gain of the camera 417 | :param color: 'master', 'magenta', 'red', 'yellow', 'green', 'cyan', 'blue' 418 | :param gain: 0-15; initial value is 4 419 | """ 420 | colors = { 421 | 'master': '0', 422 | 'magenta': '1', 423 | 'red': '2', 424 | 'yellow': '3', 425 | 'green': '4', 426 | 'cyan': '5', 427 | 'blue': '6' 428 | } 429 | if color not in colors: 430 | raise ValueError(f'"{color}" is not a valid color. Valid colors: {", ".join(colors.keys())}') 431 | 432 | if not isinstance(gain, int) or gain < 0 or gain > 15: 433 | raise ValueError('The gain must be an integer from 0 to 15 inclusive') 434 | 435 | self._send_command('04 49 00 00 0' + colors[color] + f' {gain:02x}') 436 | 437 | def set_gain(self, gain: int): 438 | """Sets the gain of the camera 439 | :param gain: 0-255 440 | """ 441 | if not isinstance(gain, int) or gain < 0 or gain > 255: 442 | raise ValueError('The gain must be an integer from 0 to 255 inclusive') 443 | 444 | self._send_command('04 4C 00 00 ' + f'{gain:02x}') 445 | 446 | def increase_gain(self): 447 | self._send_command('04 0C 02') 448 | 449 | def decrease_gain(self): 450 | self._send_command('04 0C 03') 451 | 452 | def reset_gain(self): 453 | self._send_command('04 0C 00') 454 | 455 | def autoexposure_mode(self, mode: str): 456 | """Sets the autoexposure mode of the camera 457 | :param mode: One of "auto", "manual", "shutter priority", "iris priority", or "bright". 458 | See the manual for an explanation of these modes. 459 | """ 460 | modes = { 461 | 'auto': '0', 462 | 'manual': '3', 463 | 'shutter priority': 'A', 464 | 'iris priority': 'B', 465 | 'bright': 'D' 466 | } 467 | mode = mode.lower() 468 | 469 | if mode not in modes: 470 | raise ValueError(f'"{mode}" is not a valid mode. Valid modes: {", ".join(modes.keys())}') 471 | 472 | self._send_command('04 39 0' + modes[mode]) 473 | 474 | def set_shutter(self, shutter: int): 475 | """Sets the shutter of the camera 476 | :param shutter: 0-21 477 | """ 478 | if not isinstance(shutter, int) or shutter < 0 or shutter > 21: 479 | raise ValueError('The shutter must be an integer from 0 to 21 inclusive') 480 | 481 | self._send_command('04 4A 00 ' + f'{shutter:02x}') 482 | 483 | def increase_shutter(self): 484 | self._send_command('04 0A 02') 485 | 486 | def decrease_shutter(self): 487 | self._send_command('04 0A 03') 488 | 489 | def reset_shutter(self): 490 | self._send_command('04 0A 00') 491 | 492 | def slow_shutter(self, mode: bool): 493 | """Sets the slow shutter mode of the camera 494 | :param mode: True for on, False for off 495 | """ 496 | if mode: 497 | self._send_command('04 5A 02') 498 | else: 499 | self._send_command('04 5A 03') 500 | 501 | def set_iris(self, iris: int): 502 | """Sets the iris of the camera 503 | :param iris: 0-17 504 | """ 505 | if not isinstance(iris, int) or iris < 0 or iris > 17: 506 | raise ValueError('The iris must be an integer from 0 to 17 inclusive') 507 | 508 | self._send_command('04 4B 00 00 ' + f'{iris:02x}') 509 | 510 | def increase_iris(self): 511 | self._send_command('04 0B 02') 512 | 513 | def decrease_iris(self): 514 | self._send_command('04 0B 03') 515 | 516 | def reset_iris(self): 517 | self._send_command('04 0B 00') 518 | 519 | def set_brightness(self, brightness: int): 520 | """Sets the brightness of the camera 521 | :param brightness: 0-255 522 | """ 523 | if not isinstance(brightness, int) or brightness < 0 or brightness > 255: 524 | raise ValueError('The brightness must be an integer from 0 to 255 inclusive') 525 | 526 | self._send_command('04 4D 00 00 ' + f'{brightness:02x}') 527 | 528 | def increase_brightness(self): 529 | self._send_command('04 0D 02') 530 | 531 | def decrease_brightness(self): 532 | self._send_command('04 0D 03') 533 | 534 | # exposure compensation 535 | 536 | def backlight(self, mode: bool): 537 | """Sets the backlight compensation mode of the camera 538 | :param mode: True for on, False for off 539 | """ 540 | if mode: 541 | self._send_command('04 33 02') 542 | else: 543 | self._send_command('04 33 03') 544 | 545 | def set_aperture(self, aperture: int): 546 | """Sets the aperture of the camera 547 | :param aperture: 0-255 548 | """ 549 | if not isinstance(aperture, int) or aperture < 0 or aperture > 255: 550 | raise ValueError('The aperture must be an integer from 0 to 255 inclusive') 551 | 552 | self._send_command('04 42 00 00 ' + f'{aperture:02x}') 553 | 554 | def increase_aperture(self): 555 | self._send_command('04 02 02') 556 | 557 | def decrease_aperture(self): 558 | self._send_command('04 02 03') 559 | 560 | def reset_aperture(self): 561 | self._send_command('04 02 00') 562 | 563 | def flip_horizontal(self, flip_mode: bool): 564 | """Sets the horizontal flip mode of the camera 565 | :param value: True for horizontal flip mode, False for normal mode 566 | """ 567 | if flip_mode: 568 | self._send_command('04 61 02') 569 | else: 570 | self._send_command('04 61 03') 571 | 572 | def flip_vertical(self, flip_mode: bool): 573 | """Sets the vertical flip (mount) mode of the camera 574 | :param flip_mode: True for vertical flip mode, False for normal mode 575 | """ 576 | if flip_mode: 577 | self._send_command('04 66 02') 578 | else: 579 | self._send_command('04 66 03') 580 | 581 | def flip(self, horizontal: bool, vertical: bool): 582 | """Sets the horizontal and vertical flip modes of the camera 583 | :param horizontal: True for horizontal flip mode, False for normal mode 584 | :param vertical: True for vertical flip mode, False for normal mode 585 | """ 586 | if horizontal and vertical: 587 | self._send_command('04 A4 03') 588 | elif vertical: 589 | self._send_command('04 A4 02') 590 | elif horizontal: 591 | self._send_command('04 A4 01') 592 | else: 593 | self._send_command('04 A4 00') 594 | 595 | # noise reduction 2d 596 | 597 | # noise reduction 3d 598 | 599 | def defog(self, mode: bool): 600 | """Sets the defog mode of the camera, not supported on all cameras 601 | :param value: True for defog mode, False for normal mode 602 | """ 603 | if mode: 604 | self._send_command('04 37 02 00') 605 | else: 606 | self._send_command('04 37 03 00') 607 | 608 | def save_preset(self, preset_num: int): 609 | """Saves many of the camera's settings in one of 16 slots (some cameras even have as many as 255 slots)""" 610 | if not 0 <= preset_num <= 255: 611 | raise ValueError('Preset number must be 0-255 inclusive') 612 | self._send_command(f'04 3F 01 {preset_num:02x}') 613 | 614 | def recall_preset(self, preset_num: int): 615 | """Instructs the camera to recall one of the 255 saved presets""" 616 | if not 0 <= preset_num <= 255: 617 | raise ValueError('Preset number must be 0-255 inclusive') 618 | self._send_command(f'04 3F 02 {preset_num:02x}') 619 | 620 | @staticmethod 621 | def _zero_padded_bytes_to_int(zero_padded: bytes, signed=True) -> int: 622 | """:param zero_padded: bytes like this: 0x01020304 623 | :param signed: is this a signed integer? 624 | :return: an integer like this 0x1234 625 | """ 626 | unpadded_bytes = bytes.fromhex(zero_padded.hex()[1::2]) 627 | return int.from_bytes(unpadded_bytes, 'big', signed=signed) 628 | 629 | def get_pantilt_position(self) -> Tuple[int, int]: 630 | """:return: two signed integers representing the absolute pan and tilt positions respectively""" 631 | response = self._send_command('06 12', query=True) 632 | pan_bytes = response[2:6] 633 | tilt_bytes = response[6:10] 634 | return self._zero_padded_bytes_to_int(pan_bytes), self._zero_padded_bytes_to_int(tilt_bytes) 635 | 636 | def get_zoom_position(self) -> int: 637 | """:return: an unsigned integer representing the absolute zoom position""" 638 | response = self._send_command('04 47', query=True) 639 | return self._zero_padded_bytes_to_int(response[1:], signed=False) 640 | 641 | def get_focus_mode(self) -> str: 642 | """:return: either 'auto' or 'manual' 643 | :raises ViscaException: if the response is not 2 or 3 644 | """ 645 | modes = {2: 'auto', 3: 'manual'} 646 | response = self._send_command('04 38', query=True) 647 | try: 648 | mode = modes[response[-1]] 649 | except KeyError: 650 | mode = 'unknown' 651 | raise ViscaException(response) 652 | return mode 653 | 654 | # other inquiry commands -------------------------------------------------------------------------------- /visca_over_ip/exceptions.py: -------------------------------------------------------------------------------- 1 | class ViscaException(RuntimeError): 2 | """Raised when the camera doesn't like a message that it received""" 3 | 4 | def __init__(self, response_body): 5 | self.status_code = response_body[2] 6 | descriptions = { 7 | 1: 'Message length error', 8 | 2: 'Syntax error', 9 | 3: 'Command buffer full', 10 | 4: 'Command cancelled', 11 | 5: 'No socket', 12 | 0x41: 'Command not executable' 13 | } 14 | self.description = descriptions[self.status_code] 15 | 16 | super().__init__(f'Error when executing command: {self.description}') 17 | 18 | 19 | class NoQueryResponse(TimeoutError): 20 | """Raised when a response cannot be obtained to a query after a number of retries""" 21 | --------------------------------------------------------------------------------