├── .flake8 ├── .github └── workflows │ └── format.yml ├── .gitignore ├── LICENSE ├── README.md ├── img └── shot.png ├── pyvidctrl ├── __init__.py ├── __main__.py ├── ctrl_widgets.py ├── video_controller.py └── widgets.py └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = F403, F405, W503 3 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality Checks 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | format: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout sources 14 | uses: actions/checkout@v2 15 | - name: Setup Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.10' 19 | - name: Install yapf 20 | run: pip install yapf 21 | - name: Format the code 22 | run: | 23 | python -m yapf -p -i pyvidctrl/* 24 | test $(git status --porcelain | wc -l) -eq 0 || { git diff; false; } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *pyc 2 | /dist/ 3 | venv 4 | /*.egg-info 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyvidctrl 2 | 3 | Copyright (c) 2018-2021 [Antmicro](https://www.antmicro.com) 4 | 5 | A small python utility for controlling video4linux cameras. 6 | It queries user-controls from the v4l2 devices and creates a TUI to display and adjust their values. 7 | 8 | Features vi-like keybindings. 9 | 10 | ![](img/shot.png) 11 | 12 | ## Keybindings 13 | 14 | | Key | Function | 15 | |-------|----------------------------------------------------------| 16 | | q | quit app | 17 | | ? | toggle help | 18 | | s | toggle statusline | 19 | | ⇧ ⇆ | select previous tab | 20 | | ⇆ | select next tab | 21 | | d | reset to default | 22 | | D | reset all to default | 23 | | k / ↑ | select previous control | 24 | | j / ↓ | select next control | 25 | | u / , | decrease value by step | 26 | | p / . | increase value by step | 27 | | U / < | decrease value by 10 steps | 28 | | P / > | increase value by 10 steps | 29 | | h / ← | decrease value by 1% / set value false / previous choice | 30 | | l / → | increase value by 1% / set value true / next choice | 31 | |H / ⇧ ←| decrease value by 10% | 32 | |L / ⇧ →| increase value by 10% | 33 | | ^ / ⇤ | set value to minimum | 34 | | $ / ⇥ | set value to maximum | 35 | | ⏎ | negate value / click button | 36 | 37 | 38 | ## Command Line Options 39 | 40 | | Option | Description | 41 | |--------|---------------------------------------------------------------------------------------------------------| 42 | | -r | Restore current parameter values. Optionally takes a filename as an argument and restores from that file. If no filename is specified, it restores from a file named '.pyvidctrl-' followed by the driver name. | 43 | | -s | Store current parameter values. Optionally takes a filename as an argument and saves to that file. If no filename is specified, it saves to a file named '.pyvidctrl-' followed by the driver name. | 44 | | -d | Specifies the path to the camera device node or its ID. Default is "/dev/video0". | 45 | 46 | 47 | ## Installation 48 | 49 | pip install git+https://github.com/antmicro/pyvidctrl 50 | -------------------------------------------------------------------------------- /img/shot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antmicro/pyvidctrl/865d76b3b686c22a74e02b75b931d18138e81638/img/shot.png -------------------------------------------------------------------------------- /pyvidctrl/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antmicro/pyvidctrl/865d76b3b686c22a74e02b75b931d18138e81638/pyvidctrl/__init__.py -------------------------------------------------------------------------------- /pyvidctrl/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from v4l2 import * 4 | from fcntl import ioctl 5 | 6 | from itertools import chain 7 | import signal 8 | import sys 9 | import json 10 | import argparse 11 | import errno 12 | 13 | from .widgets import * 14 | from .ctrl_widgets import * 15 | from .video_controller import VideoController 16 | 17 | import curses 18 | from curses import ( 19 | KEY_UP, 20 | KEY_DOWN, 21 | KEY_LEFT, 22 | KEY_RIGHT, 23 | KEY_SLEFT, 24 | KEY_SRIGHT, 25 | KEY_HOME, 26 | KEY_END, 27 | ) 28 | 29 | KEY_TAB = "\t" 30 | KEY_STAB = 353 31 | 32 | 33 | def is_valid_device(device): 34 | ctrl = v4l2_queryctrl() 35 | ctrl.id = V4L2_CTRL_FLAG_NEXT_CTRL 36 | try: 37 | ioctl(device, VIDIOC_QUERYCTRL, ctrl) 38 | except OSError as e: 39 | # error code returned when device 40 | # gets disconnected 41 | return e.errno != errno.ENODEV 42 | 43 | return True 44 | 45 | 46 | def query_v4l2_ctrls(dev): 47 | ctrl_id = V4L2_CTRL_FLAG_NEXT_CTRL 48 | current_class = "User Controls" 49 | controls = {current_class: []} 50 | 51 | while True: 52 | ctrl = v4l2_query_ext_ctrl() 53 | ctrl.id = ctrl_id 54 | try: 55 | ioctl(dev, VIDIOC_QUERY_EXT_CTRL, ctrl) 56 | except OSError: 57 | # we reached last control 58 | break 59 | 60 | if ctrl.type == V4L2_CTRL_TYPE_CTRL_CLASS: 61 | current_class = ctrl.name.decode("utf-8") 62 | controls[current_class] = [] 63 | 64 | controls[current_class].append(ctrl) 65 | 66 | ctrl_id = ctrl.id | V4L2_CTRL_FLAG_NEXT_CTRL 67 | 68 | return controls 69 | 70 | 71 | def query_tegra_ctrls(dev): 72 | """This function supports deprecated TEGRA_CAMERA_CID_* API""" 73 | ctrls = [] 74 | 75 | ctrlid = TEGRA_CAMERA_CID_BASE 76 | 77 | ctrl = v4l2_queryctrl() 78 | ctrl.id = ctrlid 79 | 80 | while ctrl.id < TEGRA_CAMERA_CID_LASTP1: 81 | try: 82 | ioctl(dev, VIDIOC_QUERYCTRL, ctrl) 83 | except IOError as e: 84 | if e.errno != errno.EINVAL: 85 | break 86 | ctrl = v4l2_queryctrl() 87 | ctrlid += 1 88 | ctrl.id = ctrlid 89 | continue 90 | 91 | if not ctrl.flags & V4L2_CTRL_FLAG_DISABLED: 92 | ctrls.append(ctrl) 93 | 94 | ctrl = v4l2_queryctrl() 95 | ctrlid += 1 96 | ctrl.id = ctrlid 97 | 98 | return {"Tegra Controls": ctrls} 99 | 100 | 101 | def query_ctrls(dev): 102 | ctrls_v4l2 = query_v4l2_ctrls(dev) 103 | ctrls_tegra = query_tegra_ctrls(dev) 104 | 105 | return {**ctrls_v4l2, **ctrls_tegra} 106 | 107 | 108 | def query_driver(dev): 109 | try: 110 | cp = v4l2_capability() 111 | ioctl(dev, VIDIOC_QUERYCAP, cp) 112 | return cp.driver 113 | except Exception: 114 | return b"unknown" 115 | 116 | 117 | class App(Widget): 118 | 119 | def __init__(self, device): 120 | self.running = True 121 | self.in_help = False 122 | 123 | self.device = device 124 | self.ctrls = query_ctrls(device) 125 | 126 | if len(sum(self.ctrls.values(), start=[])) == 0: 127 | return 128 | 129 | tab_titles = [] 130 | video_controllers = [] 131 | for name, ctrls in self.ctrls.items(): 132 | ctrl_widgets = [] 133 | for ctrl in ctrls: 134 | try: 135 | ctrl_widgets.append(CtrlWidget.create(device, ctrl)) 136 | except KeyError: 137 | # XXX quietly skip unsupported control types 138 | continue 139 | if 0 < len(ctrl_widgets): 140 | video_controllers.append(VideoController(device, ctrl_widgets)) 141 | tab_titles.append(name) 142 | 143 | self.video_controller_tabs = TabbedView(video_controllers, tab_titles) 144 | 145 | def start_tui(self): 146 | self.win = curses.initscr() 147 | 148 | curses.start_color() 149 | curses.noecho() 150 | curses.curs_set(False) 151 | self.win.keypad(True) 152 | curses.halfdelay(10) 153 | 154 | try: 155 | curses.init_pair(1, curses.COLOR_BLUE, curses.COLOR_BLACK) 156 | curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK) 157 | curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) 158 | curses.init_pair(7, curses.COLOR_WHITE, 236) 159 | curses.init_pair(8, curses.COLOR_YELLOW, 236) 160 | except Exception: 161 | # continue with the default settings 162 | pass 163 | 164 | def getch(self): 165 | return self.win.getch() 166 | 167 | def help(self): 168 | self.in_help = not self.in_help 169 | 170 | def draw_help(self, window, w, h, x, y, color): 171 | keys = {} 172 | for kb in KeyBind.KEYBINDS: 173 | help_texts = keys.setdefault(kb.display, []) 174 | if kb.help_text not in help_texts: 175 | help_texts.append(kb.help_text) 176 | 177 | for i, (key, help_texts) in enumerate(keys.items(), y): 178 | Label(f"{key:^3} - {' / '.join(help_texts)}").draw( 179 | window, w, h, x, i, color) 180 | 181 | def draw(self): 182 | h, w = self.win.getmaxyx() 183 | 184 | self.win.erase() 185 | 186 | title = Label("pyVidController - press ? for help") 187 | title.draw(self.win, w, 1, 0, 0, 188 | curses.color_pair(2) | curses.A_REVERSE) 189 | 190 | if self.in_help: 191 | self.draw_help(self.win, w - 6, h - 2, 3, 2, curses.color_pair(0)) 192 | return 193 | 194 | if len(sum(self.ctrls.values(), start=[])) == 0: 195 | Label("There are no controls available for camera").draw( 196 | self.win, w, 1, 2, 2, curses.color_pair(2)) 197 | return 198 | 199 | self.video_controller_tabs.draw(self.win, w - 6, h - 2, 3, 2) 200 | 201 | def on_keypress(self, key): 202 | should_continue = True 203 | if hasattr(self, "video_controller_tabs"): 204 | should_continue = self.video_controller_tabs.on_keypress(key) 205 | 206 | if should_continue: 207 | return super().on_keypress(key) 208 | 209 | def store_ctrls(self, fname=None): 210 | driver = query_driver(self.device) 211 | fname = fname if fname else ".pyvidctrl-" + driver.decode("utf-8") 212 | if not hasattr(self, "video_controller_tabs"): 213 | print(f"WARNING: Device {driver.decode('ascii')} has no controls") 214 | with open(fname, "w") as fd: 215 | json.dump([], fd, indent=4) 216 | return 0 217 | 218 | flattened_cw = chain.from_iterable( 219 | vc.ctrls for vc in self.video_controller_tabs.widgets) 220 | 221 | config = [{ 222 | "id": cw.ctrl.id, 223 | "name": cw.name, 224 | "type": cw.ctrl.type, 225 | "value": cw.value, 226 | } for cw in flattened_cw] 227 | 228 | with open(fname, "w") as fd: 229 | json.dump(config, fd, indent=4) 230 | 231 | return 0 232 | 233 | def restore_ctrls(self, fname=None): 234 | driver = query_driver(self.device) 235 | fname = fname if fname else ".pyvidctrl-" + driver.decode("utf-8") 236 | try: 237 | with open(fname, "r") as fd: 238 | config = json.load(fd) 239 | except FileNotFoundError: 240 | print("No", fname, "file in current directory!") 241 | return 1 242 | except Exception as e: 243 | print("Unable to read the config file!") 244 | print(e) 245 | return 1 246 | 247 | if not hasattr(self, "video_controller_tabs"): 248 | print(f"WARNING: Device {driver.decode('ascii')} has no controls.") 249 | return 0 250 | 251 | flattened_cw = chain.from_iterable( 252 | vc.ctrls for vc in self.video_controller_tabs.widgets) 253 | 254 | id_cw_mapping = {cw.ctrl.id: cw for cw in flattened_cw} 255 | 256 | for c in config: 257 | cw = id_cw_mapping.get(c["id"], None) 258 | if cw is not None: 259 | cw.value = c["value"] 260 | else: 261 | print( 262 | "Couldn't restore value of", 263 | c["name"], 264 | f"control (id: {c['id']})", 265 | ) 266 | 267 | return 0 268 | 269 | def end(self): 270 | self.running = False 271 | curses.nocbreak() 272 | self.win.keypad(False) 273 | curses.echo() 274 | curses.endwin() 275 | 276 | 277 | KeyBind(App, "q", App.end, "quit app") 278 | KeyBind(App, "?", App.help, "toggle help") 279 | KeyBind(App, "s", CtrlWidget.toggle_statusline, "toggle statusline") 280 | 281 | KeyBind(TabbedView, KEY_STAB, TabbedView.prev, "select previous tab", "⇧ ⇆") 282 | KeyBind(TabbedView, KEY_TAB, TabbedView.next, "select next tab", "⇆") 283 | 284 | KeyBind( 285 | VideoController, 286 | "d", 287 | VideoController.set_default_selected, 288 | "reset to default", 289 | ) 290 | KeyBind( 291 | VideoController, 292 | "D", 293 | VideoController.set_default_all, 294 | "reset all to default", 295 | ) 296 | KeyBind(VideoController, "k", VideoController.prev, "select previous control") 297 | KeyBind( 298 | VideoController, 299 | KEY_UP, 300 | VideoController.prev, 301 | "select previous control", 302 | "↑", 303 | ) 304 | KeyBind(VideoController, "j", VideoController.next, "select next control") 305 | KeyBind( 306 | VideoController, 307 | KEY_DOWN, 308 | VideoController.next, 309 | "select next control", 310 | "↓", 311 | ) 312 | 313 | KeyBind(IntCtrl, "u", lambda s: s.change_step(-1), "decrease value by step") 314 | KeyBind(IntCtrl, ",", lambda s: s.change_step(-1), "decrease value by step") 315 | KeyBind(IntCtrl, "p", lambda s: s.change_step(+1), "increase value by step") 316 | KeyBind(IntCtrl, ".", lambda s: s.change_step(+1), "increase value by step") 317 | 318 | KeyBind( 319 | IntCtrl, 320 | "U", 321 | lambda s: s.change_step(-10), 322 | "decrease value by 10 steps", 323 | ) 324 | KeyBind( 325 | IntCtrl, 326 | "<", 327 | lambda s: s.change_step(-10), 328 | "decrease value by 10 steps", 329 | ) 330 | KeyBind( 331 | IntCtrl, 332 | "P", 333 | lambda s: s.change_step(+10), 334 | "increase value by 10 steps", 335 | ) 336 | KeyBind( 337 | IntCtrl, 338 | ">", 339 | lambda s: s.change_step(+10), 340 | "increase value by 10 steps", 341 | ) 342 | 343 | KeyBind(IntCtrl, "h", lambda s: s.change_percent(-1), "decrease value by 1%") 344 | KeyBind( 345 | IntCtrl, 346 | KEY_LEFT, 347 | lambda s: s.change_percent(-1), 348 | "decrease value by 1%", 349 | "←", 350 | ) 351 | KeyBind(IntCtrl, "l", lambda s: s.change_percent(+1), "increase value by 1%") 352 | KeyBind( 353 | IntCtrl, 354 | KEY_RIGHT, 355 | lambda s: s.change_percent(+1), 356 | "increase value by 1%", 357 | "→", 358 | ) 359 | 360 | KeyBind(IntCtrl, "H", lambda s: s.change_percent(-10), "decrease value by 10%") 361 | KeyBind( 362 | IntCtrl, 363 | KEY_SLEFT, 364 | lambda s: s.change_percent(-10), 365 | "decrease value by 10%", 366 | "⇧ ←", 367 | ) 368 | KeyBind(IntCtrl, "L", lambda s: s.change_percent(+10), "increase value by 10%") 369 | KeyBind( 370 | IntCtrl, 371 | KEY_SRIGHT, 372 | lambda s: s.change_percent(+10), 373 | "increase value by 10%", 374 | "⇧ →", 375 | ) 376 | 377 | KeyBind( 378 | IntCtrl, 379 | "^", 380 | lambda s: s.set_value(float("-inf")), 381 | "set value to minimum", 382 | ) 383 | KeyBind( 384 | IntCtrl, 385 | KEY_HOME, 386 | lambda s: s.set_value(float("-inf")), 387 | "set value to minimum", 388 | "⇤", 389 | ) 390 | KeyBind( 391 | IntCtrl, 392 | "$", 393 | lambda s: s.set_value(float("inf")), 394 | "set value to maximum", 395 | ) 396 | KeyBind( 397 | IntCtrl, 398 | KEY_END, 399 | lambda s: s.set_value(float("inf")), 400 | "set value to maximum", 401 | "⇥", 402 | ) 403 | 404 | KeyBind(Int64Ctrl, "u", lambda s: s.change_step(-1), "decrease value by step") 405 | KeyBind(Int64Ctrl, ",", lambda s: s.change_step(-1), "decrease value by step") 406 | KeyBind(Int64Ctrl, "p", lambda s: s.change_step(+1), "increase value by step") 407 | KeyBind(Int64Ctrl, ".", lambda s: s.change_step(+1), "increase value by step") 408 | 409 | KeyBind( 410 | Int64Ctrl, 411 | "U", 412 | lambda s: s.change_step(-10), 413 | "decrease value by 10 steps", 414 | ) 415 | KeyBind( 416 | Int64Ctrl, 417 | "<", 418 | lambda s: s.change_step(-10), 419 | "decrease value by 10 steps", 420 | ) 421 | KeyBind( 422 | Int64Ctrl, 423 | "P", 424 | lambda s: s.change_step(+10), 425 | "increase value by 10 steps", 426 | ) 427 | KeyBind( 428 | Int64Ctrl, 429 | ">", 430 | lambda s: s.change_step(+10), 431 | "increase value by 10 steps", 432 | ) 433 | 434 | KeyBind(Int64Ctrl, "h", lambda s: s.change_percent(-1), "decrease value by 1%") 435 | KeyBind( 436 | Int64Ctrl, 437 | KEY_LEFT, 438 | lambda s: s.change_percent(-1), 439 | "decrease value by 1%", 440 | "←", 441 | ) 442 | KeyBind(Int64Ctrl, "l", lambda s: s.change_percent(+1), "increase value by 1%") 443 | KeyBind( 444 | Int64Ctrl, 445 | KEY_RIGHT, 446 | lambda s: s.change_percent(+1), 447 | "increase value by 1%", 448 | "→", 449 | ) 450 | 451 | KeyBind( 452 | Int64Ctrl, 453 | "H", 454 | lambda s: s.change_percent(-10), 455 | "decrease value by 10%", 456 | ) 457 | KeyBind( 458 | Int64Ctrl, 459 | KEY_SLEFT, 460 | lambda s: s.change_percent(-10), 461 | "decrease value by 10%", 462 | "⇧ ←", 463 | ) 464 | KeyBind( 465 | Int64Ctrl, 466 | "L", 467 | lambda s: s.change_percent(+10), 468 | "increase value by 10%", 469 | ) 470 | KeyBind( 471 | Int64Ctrl, 472 | KEY_SRIGHT, 473 | lambda s: s.change_percent(+10), 474 | "increase value by 10%", 475 | "⇧ →", 476 | ) 477 | 478 | KeyBind( 479 | Int64Ctrl, 480 | "^", 481 | lambda s: s.set_value(float("-inf")), 482 | "set value to minimum", 483 | ) 484 | KeyBind( 485 | Int64Ctrl, 486 | KEY_HOME, 487 | lambda s: s.set_value(float("-inf")), 488 | "set value to minimum", 489 | "⇤", 490 | ) 491 | KeyBind( 492 | Int64Ctrl, 493 | "$", 494 | lambda s: s.set_value(float("inf")), 495 | "set value to maximum", 496 | ) 497 | KeyBind( 498 | Int64Ctrl, 499 | KEY_END, 500 | lambda s: s.set_value(float("inf")), 501 | "set value to maximum", 502 | "⇥", 503 | ) 504 | 505 | KeyBind(BoolCtrl, "h", BoolCtrl.false, "set value false") 506 | KeyBind(BoolCtrl, KEY_LEFT, BoolCtrl.false, "set value false", "←") 507 | KeyBind(BoolCtrl, "l", BoolCtrl.true, "set value true") 508 | KeyBind(BoolCtrl, KEY_RIGHT, BoolCtrl.true, "set value true", "→") 509 | KeyBind(BoolCtrl, "\n", BoolCtrl.neg, "negate value", "⏎") 510 | 511 | KeyBind(ButtonCtrl, "\n", ButtonCtrl.click, "click button", "⏎") 512 | 513 | KeyBind(MenuCtrl, "h", MenuCtrl.prev, "previous choice") 514 | KeyBind(MenuCtrl, KEY_LEFT, MenuCtrl.prev, "previous choice", "←") 515 | KeyBind(MenuCtrl, "l", MenuCtrl.next, "next choice") 516 | KeyBind(MenuCtrl, KEY_RIGHT, MenuCtrl.next, "next choice", "→") 517 | 518 | KeyBind(BitmaskCtrl, "h", BitmaskCtrl.prev, "previous nibble") 519 | KeyBind(BitmaskCtrl, KEY_LEFT, BitmaskCtrl.prev, "previous nibble", "←") 520 | KeyBind(BitmaskCtrl, "l", BitmaskCtrl.next, "next nibble") 521 | KeyBind(BitmaskCtrl, KEY_RIGHT, BitmaskCtrl.next, "next nibble", "→") 522 | KeyBind(BitmaskCtrl, "k", BitmaskCtrl.inc, "increment nibble") 523 | KeyBind(BitmaskCtrl, KEY_UP, BitmaskCtrl.inc, "increment nibble", "↑") 524 | KeyBind(BitmaskCtrl, "j", BitmaskCtrl.dec, "decrement nibble") 525 | KeyBind(BitmaskCtrl, KEY_DOWN, BitmaskCtrl.dec, "decrement nibble", "↓") 526 | 527 | KeyBind(IntMenuCtrl, "h", IntMenuCtrl.prev, "previous choice") 528 | KeyBind(IntMenuCtrl, KEY_LEFT, IntMenuCtrl.prev, "previous choice", "←") 529 | KeyBind(IntMenuCtrl, "l", IntMenuCtrl.next, "next choice") 530 | KeyBind(IntMenuCtrl, KEY_RIGHT, IntMenuCtrl.next, "next choice", "→") 531 | 532 | 533 | def main(): 534 | parser = argparse.ArgumentParser( 535 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 536 | 537 | parser.add_argument( 538 | "-s", 539 | "--store", 540 | nargs='?', 541 | const=True, 542 | default=False, 543 | help= 544 | "Store current parameter values. Optionally takes a filename as an argument and saves to that file. If no filename is specified, it saves to a file named '.pyvidctrl-' followed by the driver name.", 545 | ) 546 | parser.add_argument( 547 | "-r", 548 | "--restore", 549 | nargs='?', 550 | const=True, 551 | default=False, 552 | help= 553 | "Restore current parameter values. Optionally takes a filename as an argument and restores from that file. If no filename is specified, it restores from a file named '.pyvidctrl-' followed by the driver name.", 554 | ) 555 | parser.add_argument( 556 | "-d", 557 | "--device", 558 | help="Path to the camera device node or its ID", 559 | default="/dev/video0", 560 | ) 561 | 562 | args = parser.parse_args() 563 | 564 | if args.device.isdigit(): 565 | args.device = "/dev/video" + args.device 566 | 567 | try: 568 | device = open(args.device, "r") 569 | except FileNotFoundError: 570 | print(f"There is no '{args.device}' device") 571 | return 1 572 | 573 | app = App(device) 574 | 575 | if args.store and args.restore: 576 | print("Cannot store and restore values at the same time!") 577 | return 1 578 | elif isinstance(args.store, str): 579 | print("Storing...") 580 | return app.store_ctrls(args.store) 581 | elif args.store: 582 | print("Storing...") 583 | return app.store_ctrls() 584 | elif isinstance(args.restore, str): 585 | print("Restoring...") 586 | return app.restore_ctrls(args.restore) 587 | elif args.restore: 588 | print("Restoring...") 589 | return app.restore_ctrls() 590 | 591 | signal.signal(signal.SIGINT, lambda s, f: app.end()) 592 | app.start_tui() 593 | 594 | while app.running: 595 | app.draw() 596 | 597 | c = app.getch() 598 | if 0 < c: 599 | app.on_keypress(chr(c)) 600 | 601 | if not is_valid_device(device): 602 | app.end() 603 | print("Disconnected") 604 | break 605 | 606 | 607 | if __name__ == "__main__": 608 | sys.exit(main()) 609 | -------------------------------------------------------------------------------- /pyvidctrl/ctrl_widgets.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import string 3 | from fcntl import ioctl 4 | 5 | from v4l2 import * 6 | 7 | from .widgets import * 8 | 9 | KEY_ESCAPE = "\x1b" 10 | 11 | 12 | class CtrlWidget(Row): 13 | """ 14 | Base control widget class 15 | Each CtrlWidget is a label with a name of 16 | its control and another label with text 17 | 'Not implemented!'. Child CtrlWidgets should 18 | replace that with their specific widget. 19 | """ 20 | 21 | show_statusline = False 22 | 23 | def __init__(self, device, ctrl): 24 | self.device = device 25 | self.ctrl = ctrl 26 | 27 | self.name = ctrl.name.decode("utf-8") 28 | self.label = Label(self.name) 29 | self.widget = Label("Not implemented!", align="center") 30 | 31 | self._statusline = Label("Statusline") 32 | 33 | super().__init__(self.label, Label(""), self.widget, columns=(4, 1, 4)) 34 | 35 | @staticmethod 36 | def create(device, ctrl): 37 | """ 38 | Creates and returns CtrlWidget depending 39 | on type of the passed ctrl 40 | """ 41 | 42 | return { 43 | V4L2_CTRL_TYPE_INTEGER: IntCtrl, 44 | V4L2_CTRL_TYPE_BOOLEAN: BoolCtrl, 45 | V4L2_CTRL_TYPE_MENU: MenuCtrl, 46 | V4L2_CTRL_TYPE_BUTTON: ButtonCtrl, 47 | V4L2_CTRL_TYPE_INTEGER64: Int64Ctrl, 48 | V4L2_CTRL_TYPE_CTRL_CLASS: CtrlClassCtrl, 49 | V4L2_CTRL_TYPE_STRING: StringCtrl, 50 | V4L2_CTRL_TYPE_BITMASK: BitmaskCtrl, 51 | V4L2_CTRL_TYPE_INTEGER_MENU: IntMenuCtrl, 52 | }[ctrl.type](device, ctrl) 53 | 54 | @property 55 | def value(self): 56 | gctrl = v4l2_control() 57 | gctrl.id = self.ctrl.id 58 | 59 | try: 60 | ioctl(self.device, VIDIOC_G_CTRL, gctrl) 61 | except OSError: 62 | return None 63 | 64 | return gctrl.value 65 | 66 | @value.setter 67 | def value(self, value): 68 | sctrl = v4l2_control() 69 | sctrl.id = self.ctrl.id 70 | sctrl.value = value 71 | 72 | try: 73 | ioctl(self.device, VIDIOC_S_CTRL, sctrl) 74 | except OSError: 75 | # can fail as some controls can be read-only 76 | # both explicitly (by setting flag) or implicitly 77 | return 78 | 79 | def update(self): 80 | """ 81 | Updates child widgets with its value 82 | Also re-query entire control to update flags 83 | """ 84 | 85 | ctrl = v4l2_query_ext_ctrl() 86 | ctrl.id = self.ctrl.id 87 | ioctl(self.device, VIDIOC_QUERY_EXT_CTRL, ctrl) 88 | self.ctrl = ctrl 89 | 90 | v = self.value 91 | for w in self.widgets: 92 | w.value = v 93 | 94 | def get_flags_str(self): 95 | flags = self.ctrl.flags 96 | ret = [] 97 | if flags & V4L2_CTRL_FLAG_DISABLED: 98 | ret.append("disabled") 99 | if flags & V4L2_CTRL_FLAG_GRABBED: 100 | ret.append("grabbed") 101 | if flags & V4L2_CTRL_FLAG_READ_ONLY: 102 | ret.append("read only") 103 | if flags & V4L2_CTRL_FLAG_UPDATE: 104 | ret.append("update") 105 | if flags & V4L2_CTRL_FLAG_INACTIVE: 106 | ret.append("inactive") 107 | if flags & V4L2_CTRL_FLAG_SLIDER: 108 | ret.append("slider") 109 | if flags & V4L2_CTRL_FLAG_WRITE_ONLY: 110 | ret.append("write only") 111 | if flags & V4L2_CTRL_FLAG_VOLATILE: 112 | ret.append("volatile") 113 | if flags & V4L2_CTRL_FLAG_HAS_PAYLOAD: 114 | ret.append("has payload") 115 | if flags & V4L2_CTRL_FLAG_EXECUTE_ON_WRITE: 116 | ret.append("execute on write") 117 | if flags & V4L2_CTRL_FLAG_MODIFY_LAYOUT: 118 | ret.append("modify layout") 119 | 120 | return ", ".join(ret) 121 | 122 | @property 123 | def statusline(self): 124 | return self._statusline 125 | 126 | def toggle_statusline(self): 127 | CtrlWidget.show_statusline = not CtrlWidget.show_statusline 128 | 129 | def draw_statusline(self, window): 130 | _, w = window.getmaxyx() 131 | 132 | self.statusline.draw(window, w, 1, 0, 0, 133 | curses.color_pair(3) | curses.A_REVERSE) 134 | 135 | def draw(self, window, w, h, x, y, color): 136 | """Updates itself and then draws""" 137 | 138 | self.update() 139 | super().draw(window, w, h, x, y, color) 140 | 141 | 142 | class IntCtrl(CtrlWidget): 143 | """ 144 | Integer type control widget 145 | Uses LabeledBar to display its value 146 | """ 147 | 148 | def __init__(self, device, ctrl): 149 | super().__init__(device, ctrl) 150 | self.bar = BarLabeled(ctrl.minimum, ctrl.maximum, self.value) 151 | self.widgets[2] = self.bar 152 | 153 | def change_step(self, x): 154 | self.set_value(self.value + x * self.ctrl.step) 155 | 156 | def change_percent(self, x): 157 | one_percent = (round((self.ctrl.maximum - self.ctrl.minimum) / 100) 158 | or self.ctrl.step) 159 | self.set_value(self.value + x * one_percent) 160 | 161 | def set_value(self, value): 162 | if value < self.ctrl.minimum: 163 | self.value = self.ctrl.minimum 164 | elif self.ctrl.maximum < value: 165 | self.value = self.ctrl.maximum 166 | else: 167 | self.value = int(value) 168 | 169 | @property 170 | def statusline(self): 171 | minimum = self.ctrl.minimum 172 | maximum = self.ctrl.maximum 173 | step = self.ctrl.step 174 | default = self.ctrl.default 175 | value = self.value 176 | flags = self.get_flags_str() 177 | return Label(", ".join(( 178 | "type=Integer", 179 | f"{minimum=}", 180 | f"{maximum=}", 181 | f"{step=}", 182 | f"{default=}", 183 | f"{value=}", 184 | f"{flags=}", 185 | ))) 186 | 187 | 188 | class BoolCtrl(CtrlWidget): 189 | """ 190 | Boolean type control widget 191 | Uses TrueFalse to display its value 192 | """ 193 | 194 | def __init__(self, device, ctrl): 195 | super().__init__(device, ctrl) 196 | self.widgets[2] = TrueFalse(self.value) 197 | 198 | def true(self): 199 | self.value = True 200 | 201 | def false(self): 202 | self.value = False 203 | 204 | def neg(self): 205 | self.value = not self.value 206 | 207 | @property 208 | def statusline(self): 209 | default = self.ctrl.default 210 | value = self.value 211 | flags = self.get_flags_str() 212 | return Label(", ".join(( 213 | "type=Boolean", 214 | f"{default=}", 215 | f"{value=}", 216 | f"{flags=}", 217 | ))) 218 | 219 | 220 | class MenuCtrl(CtrlWidget): 221 | """ 222 | Menu type control widget 223 | Uses Menu to display its value 224 | """ 225 | 226 | def __init__(self, device, ctrl): 227 | super().__init__(device, ctrl) 228 | 229 | querymenu = v4l2_querymenu() 230 | querymenu.id = ctrl.id 231 | 232 | options = {} 233 | for i in range(ctrl.minimum, ctrl.maximum + 1): 234 | querymenu.index = i 235 | try: 236 | ioctl(device, VIDIOC_QUERYMENU, querymenu) 237 | options[i] = querymenu.name.decode("utf-8") 238 | except OSError: 239 | # querymenu can fail for given index, but there can 240 | # still be more valid indexes 241 | pass 242 | 243 | self.menu = Menu(options) 244 | self.widgets[2] = self.menu 245 | 246 | def next(self): 247 | """Selects next option""" 248 | 249 | self.menu.next() 250 | self.value = self.menu.value 251 | 252 | def prev(self): 253 | """Selects previous option""" 254 | 255 | self.menu.prev() 256 | self.value = self.menu.value 257 | 258 | @property 259 | def statusline(self): 260 | minimum = self.ctrl.minimum 261 | maximum = self.ctrl.maximum 262 | default = self.ctrl.default 263 | value = self.value 264 | flags = self.get_flags_str() 265 | return Label(", ".join(( 266 | "type=Menu", 267 | f"{minimum=}", 268 | f"{maximum=}", 269 | f"{default=}", 270 | f"{value=}", 271 | f"{flags=}", 272 | ))) 273 | 274 | 275 | class ButtonCtrl(CtrlWidget): 276 | """ 277 | Button type control widget 278 | Uses Button with 'Click me' text 279 | """ 280 | 281 | def __init__(self, device, ctrl): 282 | super().__init__(device, ctrl) 283 | self.widgets[2] = Button("Click me") 284 | 285 | def click(self): 286 | """ 287 | Button type controls need to set its 288 | value to 1, and after a while they reset 289 | themselves to 0 290 | """ 291 | 292 | self.value = 1 293 | 294 | @property 295 | def value(self): 296 | return 0 297 | 298 | @value.setter 299 | def value(self, value): 300 | """ 301 | Same as default, but needs to be here, as 302 | property method is reimplemented 303 | """ 304 | 305 | sctrl = v4l2_control() 306 | sctrl.id = self.ctrl.id 307 | sctrl.value = value 308 | 309 | try: 310 | ioctl(self.device, VIDIOC_S_CTRL, sctrl) 311 | except OSError: 312 | return 313 | 314 | @property 315 | def statusline(self): 316 | flags = self.get_flags_str() 317 | return Label(f"type=Button, {flags=}") 318 | 319 | 320 | class Int64Ctrl(IntCtrl): 321 | """ 322 | Integer64 type control widget 323 | Same as Integer one, except for statusline 324 | """ 325 | 326 | @property 327 | def value(self): 328 | ectrl = v4l2_ext_control() 329 | ectrl.id = self.ctrl.id 330 | 331 | ectrls = v4l2_ext_controls() 332 | ectrls.controls = ctypes.pointer(ectrl) 333 | ectrls.count = 1 334 | 335 | try: 336 | ioctl(self.device, VIDIOC_G_EXT_CTRLS, ectrls) 337 | except OSError: 338 | return None 339 | 340 | return ectrl.value64 341 | 342 | @value.setter 343 | def value(self, value): 344 | ectrl = v4l2_ext_control() 345 | ectrl.id = self.ctrl.id 346 | ectrl.value64 = value 347 | 348 | ectrls = v4l2_ext_controls() 349 | ectrls.controls = ctypes.pointer(ectrl) 350 | ectrls.count = 1 351 | 352 | try: 353 | ioctl(self.device, VIDIOC_S_EXT_CTRLS, ectrls) 354 | except OSError: 355 | # can fail as some controls can be read-only 356 | # both explicitly (by setting flag) or implicitly 357 | return 358 | 359 | @property 360 | def statusline(self): 361 | minimum = self.ctrl.minimum 362 | maximum = self.ctrl.maximum 363 | step = self.ctrl.step 364 | default = self.ctrl.default 365 | value = self.value 366 | flags = self.get_flags_str() 367 | return Label(", ".join(( 368 | "type=Integer64", 369 | f"{minimum=}", 370 | f"{maximum=}", 371 | f"{step=}", 372 | f"{default=}", 373 | f"{value=}", 374 | f"{flags=}", 375 | ))) 376 | 377 | 378 | class CtrlClassCtrl(CtrlWidget): 379 | """ 380 | Control Class control widget 381 | Removes second widget to show just its name, 382 | as it's just a category name control 383 | """ 384 | 385 | def __init__(self, device, ctrl): 386 | super().__init__(device, ctrl) 387 | self.widgets = [Label(self.name, align="center")] 388 | self.columns = (1, ) 389 | 390 | 391 | class StringCtrl(CtrlWidget): 392 | """ 393 | String type control widget 394 | Uses TextField to display its value. 395 | Enter key toggles edit mode and Escape aborts edit mode and 396 | restores previous text. 397 | 398 | String type controls use minimum and maximum fields to limit 399 | number of characters stored. 400 | When upper limit is reached, then further keys are ignored 401 | (except Enter and Escape). 402 | When minimum number of characters is not present, then spaces 403 | are appended at the end. 404 | """ 405 | 406 | def __init__(self, device, ctrl): 407 | super().__init__(device, ctrl) 408 | self.text_field = TextField(self.value) 409 | self.widgets[2] = self.text_field 410 | 411 | @property 412 | def value(self): 413 | ectrl = v4l2_ext_control() 414 | ectrl.id = self.ctrl.id 415 | ectrl.size = self.ctrl.elem_size 416 | ectrl.string = bytes(self.ctrl.maximum + 1) 417 | 418 | ectrls = v4l2_ext_controls() 419 | ectrls.controls = ctypes.pointer(ectrl) 420 | ectrls.count = 1 421 | 422 | try: 423 | ioctl(self.device, VIDIOC_G_EXT_CTRLS, ectrls) 424 | except OSError: 425 | return None 426 | 427 | return ectrl.string.decode("utf-8") 428 | 429 | @value.setter 430 | def value(self, value): 431 | value = str(value) 432 | if len(value) < self.ctrl.minimum: 433 | value = " " * self.ctrl.minimum 434 | 435 | ectrl = v4l2_ext_control() 436 | ectrl.id = self.ctrl.id 437 | ectrl.string = value.encode("utf-8") 438 | ectrl.size = self.ctrl.elem_size 439 | 440 | ectrls = v4l2_ext_controls() 441 | ectrls.controls = ctypes.pointer(ectrl) 442 | ectrls.count = 1 443 | 444 | try: 445 | ioctl(self.device, VIDIOC_S_EXT_CTRLS, ectrls) 446 | except OSError: 447 | # can fail as some controls can be read-only 448 | # both explicitly (by setting flag) or implicitly 449 | return 450 | 451 | def on_keypress(self, key): 452 | in_edit = self.text_field.in_edit 453 | 454 | if in_edit and key == "\n": 455 | self.text_field.edit() 456 | self.value = self.text_field.buffer 457 | elif in_edit and ord(key) == curses.KEY_BACKSPACE: 458 | self.text_field.buffer = self.text_field.buffer[:-1] 459 | elif in_edit and key == KEY_ESCAPE: 460 | self.text_field.abort() 461 | elif in_edit: 462 | if len(self.text_field.buffer) < self.ctrl.maximum: 463 | self.text_field.buffer += key 464 | elif key == "\n": 465 | self.text_field.edit() 466 | else: 467 | return super().on_keypress(key) 468 | 469 | @property 470 | def statusline(self): 471 | minimum = self.ctrl.minimum 472 | maximum = self.ctrl.maximum 473 | default = self.ctrl.default 474 | value = self.value 475 | flags = self.get_flags_str() 476 | return Label(", ".join(( 477 | "type=String", 478 | f"{minimum=}", 479 | f"{maximum=}", 480 | f"{default=}", 481 | f"{value=}", 482 | f"{flags=}", 483 | ))) 484 | 485 | 486 | class BitmaskCtrl(CtrlWidget): 487 | """ 488 | Bitmask type control widget 489 | Uses TextField to display its value. 490 | Limits possible characters to valid hex digits. 491 | """ 492 | 493 | class BitmaskEditWidget(Widget): 494 | 495 | def __init__(self, value=0): 496 | self.value = value 497 | self.in_edit = False 498 | self.selected = 0 499 | 500 | def draw(self, window, w, h, x, y, color): 501 | render = (self.buffer if self.in_edit else self.value.to_bytes( 502 | 4, "big").hex()) 503 | 504 | if self.in_edit: 505 | left_w = (w - len(render) + 1) // 2 506 | safe_addstr(window, y, x, " " * left_w, color) 507 | x += left_w 508 | 509 | sel = self.selected 510 | 511 | safe_addstr(window, y, x, render[:sel], color) 512 | x += sel 513 | safe_addstr(window, y, x, render[sel], 514 | color | curses.A_REVERSE) 515 | x += 1 516 | safe_addstr(window, y, x, render[sel + 1:], color) 517 | x += len(render) - sel - 1 518 | 519 | right_w = w - len(render) - left_w 520 | safe_addstr(window, y, x, " " * right_w, color) 521 | else: 522 | safe_addstr(window, y, x, render.center(w), color) 523 | 524 | def set(self, char): 525 | sel = self.selected 526 | self.buffer = self.buffer[:sel] + char + self.buffer[sel + 1:] 527 | 528 | def next(self): 529 | if self.in_edit: 530 | self.selected = min(self.selected + 1, 7) 531 | 532 | def prev(self): 533 | if self.in_edit: 534 | self.selected = max(self.selected - 1, 0) 535 | 536 | def inc(self): 537 | if self.in_edit: 538 | sel = self.selected 539 | self.set(hex((int(self.buffer[sel], 16) + 1) % 16)[2:]) 540 | else: 541 | return True 542 | 543 | def dec(self): 544 | if self.in_edit: 545 | sel = self.selected 546 | self.set(hex((int(self.buffer[sel], 16) - 1) % 16)[2:]) 547 | else: 548 | return True 549 | 550 | def edit(self): 551 | """ 552 | Switches from non-edit to edit mode 553 | and vice versa. Copies previous text 554 | to the `buffer` field, which should 555 | be modified. On exit from edit mode 556 | it copies `buffer` to `value`. 557 | """ 558 | 559 | if not self.in_edit: 560 | self.buffer = self.value.to_bytes(4, "big").hex() 561 | self.in_edit = True 562 | else: 563 | self.value = int.from_bytes(bytes.fromhex(self.buffer), "big") 564 | self.in_edit = False 565 | 566 | def abort(self): 567 | """ 568 | Aborts edit mode clearing buffer 569 | and restoring previous value. 570 | """ 571 | 572 | self.in_edit = False 573 | self.buffer = self.value.to_bytes(4, "big").hex() 574 | 575 | def __init__(self, device, ctrl): 576 | super().__init__(device, ctrl) 577 | self.edit_widget = BitmaskCtrl.BitmaskEditWidget(self.value) 578 | self.widgets[2] = self.edit_widget 579 | 580 | @property 581 | def value(self): 582 | ectrl = v4l2_ext_control() 583 | ectrls = v4l2_ext_controls() 584 | ectrl.id = self.ctrl.id 585 | ectrls.controls = ctypes.pointer(ectrl) 586 | ectrls.count = 1 587 | 588 | try: 589 | ioctl(self.device, VIDIOC_G_EXT_CTRLS, ectrls) 590 | except OSError: 591 | return None 592 | 593 | return ectrl.value64 594 | 595 | @value.setter 596 | def value(self, value): 597 | ectrl = v4l2_ext_control() 598 | ectrl.id = self.ctrl.id 599 | ectrl.value64 = value 600 | 601 | ectrls = v4l2_ext_controls() 602 | ectrls.controls = ctypes.pointer(ectrl) 603 | ectrls.count = 1 604 | 605 | try: 606 | ioctl(self.device, VIDIOC_S_EXT_CTRLS, ectrls) 607 | except OSError: 608 | # can fail as some controls can be read-only 609 | # both explicitly (by setting flag) or implicitly 610 | return 611 | 612 | def on_keypress(self, key): 613 | ALLOWED_CHARS = string.hexdigits 614 | in_edit = self.edit_widget.in_edit 615 | 616 | if in_edit and key == "\n": 617 | self.edit_widget.edit() 618 | self.value = self.edit_widget.value 619 | elif in_edit and key == KEY_ESCAPE: 620 | self.edit_widget.abort() 621 | elif in_edit and key in ALLOWED_CHARS: 622 | self.edit_widget.set(key) 623 | self.next() 624 | elif key == "\n": 625 | self.edit_widget.edit() 626 | else: 627 | return super().on_keypress(key) 628 | 629 | def next(self): 630 | self.edit_widget.next() 631 | 632 | def prev(self): 633 | self.edit_widget.prev() 634 | 635 | def inc(self): 636 | return self.edit_widget.inc() 637 | 638 | def dec(self): 639 | return self.edit_widget.dec() 640 | 641 | @property 642 | def statusline(self): 643 | minimum = self.ctrl.minimum 644 | maximum = self.ctrl.maximum 645 | default = self.ctrl.default 646 | value = self.value 647 | flags = self.get_flags_str() 648 | return Label(", ".join(( 649 | "type=Bitmask", 650 | f"{minimum=}", 651 | f"{maximum=}", 652 | f"{default=}", 653 | f"{value=}", 654 | f"{flags=}", 655 | ))) 656 | 657 | 658 | class IntMenuCtrl(MenuCtrl): 659 | """ 660 | IntegerMenu type control widget 661 | Just like MenuCtrl, but doesn't decode text representations 662 | of its values, as they are numbers. 663 | """ 664 | 665 | def __init__(self, device, ctrl): 666 | super().__init__(device, ctrl) 667 | 668 | querymenu = v4l2_querymenu() 669 | querymenu.id = ctrl.id 670 | 671 | options = {} 672 | for i in range(ctrl.minimum, ctrl.maximum + 1): 673 | querymenu.index = i 674 | try: 675 | ioctl(device, VIDIOC_QUERYMENU, querymenu) 676 | options[i] = int.from_bytes(querymenu.name, "little") 677 | except OSError: 678 | # querymenu can fail for given index, but there can 679 | # still be more valid indexes 680 | pass 681 | 682 | self.menu = Menu(options) 683 | self.widgets[2] = self.menu 684 | 685 | @property 686 | def statusline(self): 687 | minimum = self.ctrl.minimum 688 | maximum = self.ctrl.maximum 689 | default = self.ctrl.default 690 | value = self.value 691 | flags = self.get_flags_str() 692 | return Label(", ".join(( 693 | "type=IntMenu", 694 | f"{minimum=}", 695 | f"{maximum=}", 696 | f"{default=}", 697 | f"{value=}", 698 | f"{flags=}", 699 | ))) 700 | -------------------------------------------------------------------------------- /pyvidctrl/video_controller.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | from .widgets import * 4 | from .ctrl_widgets import * 5 | 6 | 7 | class VideoController(Widget): 8 | """Aggregates multiple CtrlWigets, manages and draws them.""" 9 | 10 | def __init__(self, device, ctrls): 11 | self.ctrls = [c for c in ctrls if not isinstance(c, CtrlClassCtrl)] 12 | self.device = device 13 | self.visible_ctrls = slice(0, len(ctrls)) 14 | self.selected_ctrl = -1 15 | for i, c in enumerate(self.ctrls): 16 | if not isinstance(c, CtrlClassCtrl): 17 | self.selected_ctrl = i 18 | break 19 | 20 | def draw(self, window, w, h, x, y): 21 | """Draws each widget on every other line.""" 22 | 23 | assert 0 <= self.selected_ctrl < len(self.ctrls) 24 | self.visible_ctrls = slice(self.visible_ctrls.start, 25 | self.visible_ctrls.start + h // 2) 26 | 27 | for i, c in enumerate(self.ctrls[self.visible_ctrls], 28 | self.visible_ctrls.start): 29 | f = self.get_format(c) 30 | 31 | if self.selected_ctrl == i: 32 | f |= curses.color_pair(3) | curses.A_BOLD 33 | 34 | if c.ctrl.flags & (V4L2_CTRL_FLAG_DISABLED 35 | | V4L2_CTRL_FLAG_READ_ONLY 36 | | V4L2_CTRL_FLAG_INACTIVE): 37 | f |= curses.A_DIM 38 | 39 | c.draw(window, w, 1, x, y, f) 40 | 41 | if self.selected_ctrl == i and CtrlWidget.show_statusline: 42 | c.draw_statusline(window) 43 | 44 | y += 2 45 | 46 | def next(self): 47 | """Selects next CtrlWidget""" 48 | 49 | self.selected_ctrl = sc = (self.selected_ctrl + 1) % len(self.ctrls) 50 | vcs = self.visible_ctrls 51 | 52 | if sc < vcs.start: 53 | self.visible_ctrls = slice(0, vcs.stop - vcs.start) 54 | elif sc >= vcs.stop: 55 | self.visible_ctrls = slice(vcs.start + 1, vcs.stop + 1) 56 | 57 | if isinstance(self.ctrls[self.selected_ctrl], CtrlClassCtrl): 58 | self.next() 59 | 60 | def prev(self): 61 | """Selects previous CtrlWidget""" 62 | 63 | self.selected_ctrl = sc = (self.selected_ctrl - 1) % len(self.ctrls) 64 | vcs = self.visible_ctrls 65 | 66 | if sc > vcs.stop: 67 | self.visible_ctrls = slice(sc - (vcs.stop - vcs.start) + 1, sc + 1) 68 | elif sc < vcs.start: 69 | self.visible_ctrls = slice(vcs.start - 1, vcs.stop - 1) 70 | 71 | if isinstance(self.ctrls[self.selected_ctrl], CtrlClassCtrl): 72 | self.prev() 73 | 74 | def on_keypress(self, key): 75 | """ 76 | First lets selected widget resolve the keypress. 77 | If it isn't marked as resolved preforms default 78 | on_kepress action. 79 | """ 80 | 81 | should_continue = self.ctrls[self.selected_ctrl].on_keypress(key) 82 | 83 | if should_continue: 84 | return super().on_keypress(key) 85 | 86 | def set_default_selected(self): 87 | c = self.ctrls[self.selected_ctrl] 88 | c.value = c.ctrl.default 89 | 90 | def set_default_all(self): 91 | for c in self.ctrls: 92 | c.value = c.ctrl.default 93 | 94 | def get_format(self, ctrl): 95 | """Returns format specific to the CtrlWidget class.""" 96 | 97 | return { 98 | CtrlClassCtrl: curses.color_pair(1) | curses.A_UNDERLINE, 99 | }.get(type(ctrl), curses.A_NORMAL) 100 | -------------------------------------------------------------------------------- /pyvidctrl/widgets.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | 4 | def safe_addstr(window, y, x, string, attr=None): 5 | try: 6 | window.addstr(y, x, string, attr or curses.color_pair(0)) 7 | except curses.error: 8 | # curses by design throw exception after writing to lower right corner 9 | # https://docs.python.org/3/library/curses.html#curses.window.addstr 10 | pass 11 | 12 | 13 | def calc_width(current, total): 14 | try: 15 | return round(current / total) 16 | except ZeroDivisionError: 17 | return 0 18 | 19 | 20 | class KeyBind: 21 | KEYBINDS = [] 22 | 23 | def __init__(self, cls, key, handler, help_text, display=None): 24 | assert callable(handler) 25 | assert isinstance(key, str) or display is not None 26 | 27 | if isinstance(key, int): 28 | key = chr(key) 29 | 30 | self.cls = cls 31 | self.key = key 32 | self.handler = handler 33 | self.help_text = help_text 34 | self.display = display or key 35 | 36 | KeyBind.KEYBINDS.append(self) 37 | 38 | @staticmethod 39 | def get_handler(cls, key): 40 | """Search and return key handler registered for `cls` and `key`""" 41 | 42 | try: 43 | return next( 44 | filter(lambda k: k.cls == cls and k.key == key, 45 | KeyBind.KEYBINDS)) 46 | except StopIteration: 47 | return None 48 | 49 | def __call__(self, other): 50 | return self.handler(other) 51 | 52 | 53 | class Widget: 54 | 55 | @property 56 | def value(self): 57 | return self._value 58 | 59 | @value.setter 60 | def value(self, value): 61 | self._value = value 62 | 63 | def draw(self, window, w, h, x, y, color): 64 | """ 65 | Draws text on the provided window. 66 | Passed arguments `w`, `h`, `x` and `y` tell where and how big 67 | is the box for the Widget. Color is a color parameter of 68 | curses addstr. 69 | """ 70 | 71 | pass 72 | 73 | def on_keypress(self, key): 74 | """ 75 | Default keypress resolver. First searches for 76 | registered handler. If there is one, it runs it. 77 | If there is no registered handler or event wasn't 78 | exhausted, returns True 79 | """ 80 | 81 | handler = KeyBind.get_handler(self.__class__, key) 82 | 83 | if handler is not None: 84 | return handler(self) 85 | else: 86 | return True 87 | 88 | 89 | class Row(Widget): 90 | """ 91 | Row of widgets 92 | On draw, it draws contained widgets with given 93 | space distributed by values in `columns` field. 94 | Last widget in row, gets remaining width to 95 | fill given space. 96 | """ 97 | 98 | def __init__(self, *widgets, columns=None): 99 | self.widgets = list(widgets) 100 | self.columns = columns or [1 for _ in widgets] 101 | assert len(self.columns) == len(self.widgets) 102 | 103 | def draw(self, window, w, h, x, y, color): 104 | total = 0 105 | for i, (widget, col) in enumerate(zip(self.widgets, self.columns)): 106 | if i == len(self.widgets) - 1: 107 | widget_w = w - total 108 | else: 109 | widget_w = calc_width(w * col, sum(self.columns)) 110 | if widget_w > 0: 111 | widget.draw(window, widget_w, h, x + total, y, color) 112 | total += widget_w 113 | 114 | 115 | class Label(Widget): 116 | """ 117 | Label with text inside 118 | Text can be aligned to the right, left or centered 119 | When there is not enough space, it is trimmed 120 | and one character '…' ellipsis is added at the end. 121 | """ 122 | 123 | def __init__(self, text="", align="left"): 124 | assert align in ["left", "center", "right"] 125 | self.text = str(text) 126 | self.align = align 127 | 128 | def update(self, value): 129 | self.text = str(value) 130 | 131 | def draw(self, window, w, h, x, y, color): 132 | render = "" 133 | if len(self.text) > w: 134 | render = self.text[:w - 1] + "…" 135 | elif self.align == "left": 136 | render = self.text.ljust(w) 137 | elif self.align == "center": 138 | render = self.text.center(w) 139 | elif self.align == "right": 140 | render = self.text.rjust(w) 141 | 142 | safe_addstr(window, y, x, render, color) 143 | 144 | 145 | class TextField(Widget): 146 | """ 147 | Editable, single line text field 148 | Doesn't resolve events on its own. 149 | Requires another widget witch will catch events 150 | for it and manipulate its state. 151 | """ 152 | 153 | def __init__(self, value="", align="left"): 154 | assert align in ["left", "center", "right"] 155 | self.value = str(value) 156 | self.align = align 157 | self.in_edit = False 158 | 159 | def draw(self, window, w, h, x, y, color): 160 | self.value = str(self.value) 161 | render = self.buffer if self.in_edit else self.value 162 | if len(render) > w: 163 | render = render[:w - 1] + "…" 164 | elif self.align == "left": 165 | render = render.ljust(w) 166 | elif self.align == "center": 167 | render = render.center(w) 168 | elif self.align == "right": 169 | render = render.rjust(w) 170 | 171 | f = color | curses.A_ITALIC 172 | 173 | if self.in_edit: 174 | safe_addstr(window, y, x, render, f | curses.A_REVERSE) 175 | else: 176 | safe_addstr(window, y, x, render, f) 177 | 178 | def edit(self): 179 | """ 180 | Switches from non-edit to edit mode 181 | and vice versa. Copies previous text 182 | to the `buffer` field, which should 183 | be modified. On exit from edit mode 184 | it copies `buffer` to `value`. 185 | """ 186 | 187 | if not self.in_edit: 188 | self.buffer = self.value 189 | self.in_edit = True 190 | else: 191 | self.value = self.buffer 192 | self.in_edit = False 193 | 194 | def abort(self): 195 | """ 196 | Aborts edit mode clearing buffer 197 | and restoring previous value. 198 | """ 199 | 200 | self.in_edit = False 201 | 202 | 203 | class Checkbox(Widget): 204 | """ 205 | Simple checkbox widget 206 | Stores boolean and depending on value 207 | draws empty or checked box. 208 | """ 209 | 210 | def __init__(self, value=False): 211 | self.value = value 212 | 213 | def draw(self, window, w, h, x, y, color): 214 | render = ("[x]" if self.value else "[ ]").center(w) 215 | safe_addstr(window, y, x, render, color) 216 | 217 | 218 | class TrueFalse(Widget): 219 | """ 220 | More fancy checkbox 221 | Draws '[ ] False [ ] True' and depending on value 222 | checks box of one of them 223 | """ 224 | 225 | def __init__(self, value=False): 226 | self.value = value 227 | 228 | def draw(self, window, w, h, x, y, color): 229 | fbox, tbox = "[ ]", "[x]" 230 | fcolor, tcolor = curses.A_NORMAL, color | curses.A_BOLD 231 | 232 | if not self.value: 233 | fbox, tbox = tbox, fbox 234 | fcolor, tcolor = tcolor, fcolor 235 | 236 | Label(fbox + " False", align="center").draw(window, w // 2, h, x, y, 237 | fcolor) 238 | Label(tbox + " True", align="center").draw(window, w - w // 2, h, 239 | x + w // 2, y, tcolor) 240 | 241 | 242 | class Menu(Widget): 243 | """ 244 | Single choice menu 245 | Takes dictionary of options, that is 246 | pairs of 'value: displayed text'. Draws 247 | `< option text >' and if the selected option 248 | is first or last, hides one of the arrows. 249 | """ 250 | 251 | def __init__(self, options={}, selected=None): 252 | self.options = options 253 | self.keys = list(options.keys()) 254 | assert 0 < len(self.keys) 255 | 256 | if selected is not None: 257 | assert selected in options.keys() 258 | self.selected = self.keys.index(selected) 259 | else: 260 | self.selected = 0 261 | 262 | @property 263 | def value(self): 264 | return self.keys[self.selected] 265 | 266 | @value.setter 267 | def value(self, value): 268 | if value is not None and value in self.options.keys(): 269 | self.selected = self.keys.index(value) 270 | 271 | def next(self): 272 | if self.selected + 1 <= len(self.keys) - 1: 273 | self.selected = self.selected + 1 274 | 275 | def prev(self): 276 | if self.selected - 1 >= 0: 277 | self.selected = self.selected - 1 278 | 279 | def draw(self, window, w, h, x, y, color): 280 | pre = "<" if self.selected > 0 else " " 281 | post = ">" if self.selected < len(self.keys) - 1 else " " 282 | middle = str(self.options[self.value]) 283 | if w < len(middle) + 2: 284 | middle = middle[:w - 3] + "…" 285 | render = pre + middle.center(w - 2) + post 286 | safe_addstr(window, y, x, render, color) 287 | 288 | 289 | class Bar(Widget): 290 | """ 291 | Bar with a marker 292 | Displays a number selection, by showing 293 | a circle on a number line. 294 | """ 295 | 296 | def __init__(self, min, max, value=None): 297 | self.value = value if value is not None else min 298 | self.min = min 299 | self.max = max 300 | 301 | def draw(self, window, w, h, x, y, color): 302 | filled_w = calc_width(w * (self.value - self.min), 303 | (self.max - self.min)) 304 | empty_w = w - filled_w 305 | 306 | safe_addstr(window, y, x, " " * filled_w, color | curses.A_REVERSE) 307 | safe_addstr(window, y, x + filled_w, " " * empty_w, 308 | curses.color_pair(7)) 309 | 310 | 311 | class BarLabeled(Bar): 312 | """Bar containing a label""" 313 | 314 | def __init__(self, min, max, value=None, label_position="left"): 315 | super().__init__(min, max, value) 316 | 317 | assert label_position in ["left", "right"] 318 | self.label_position = label_position 319 | 320 | def draw(self, window, w, h, x, y, color): 321 | text = str(self.value) 322 | if len(text) > w: 323 | render = "…" + text[len(text) - w + 1:] 324 | else: 325 | render = text.center(w) 326 | 327 | filled_w = calc_width(w * (self.value - self.min), 328 | (self.max - self.min)) 329 | 330 | # if widget is selected 331 | if color == curses.color_pair(3) | curses.A_BOLD: 332 | empty_color = curses.color_pair(8) | curses.A_BOLD 333 | else: 334 | empty_color = curses.color_pair(7) 335 | 336 | safe_addstr(window, y, x, render[:filled_w], color | curses.A_REVERSE) 337 | safe_addstr(window, y, x + filled_w, render[filled_w:], empty_color) 338 | 339 | 340 | class Button(Widget): 341 | """ 342 | Simple button 343 | Right now it's just a label inside of square brackets 344 | """ 345 | 346 | def __init__(self, text): 347 | self.text = text 348 | 349 | def draw(self, window, w, h, x, y, color): 350 | render = "[" + self.text.center(w - 2) + "]" 351 | safe_addstr(window, y, x, render, color) 352 | 353 | 354 | class TabbedView(Widget): 355 | 356 | def __init__(self, widgets, titles=None, selected=None): 357 | assert 0 < len(widgets) 358 | self.widgets = widgets 359 | 360 | if titles is not None: 361 | assert len(titles) == len(widgets) 362 | self.titles = titles 363 | else: 364 | self.titles = [w.value for w in widgets] 365 | 366 | if selected is not None: 367 | assert 0 < selected < len(widgets) - 1 368 | self.selected = selected 369 | else: 370 | self.selected = 0 371 | 372 | def next(self): 373 | self.selected = (self.selected + 1) % len(self.widgets) 374 | 375 | def prev(self): 376 | self.selected = (self.selected - 1) % len(self.widgets) 377 | 378 | def draw(self, window, w, h, x, y): 379 | tab_w = w // len(self.widgets) 380 | 381 | for i, title in enumerate(self.titles): 382 | tab_color = curses.color_pair(7) 383 | if i == self.selected: 384 | tab_color |= curses.A_REVERSE 385 | 386 | if i == len(self.titles) - 1: 387 | tw = w - tab_w * i 388 | else: 389 | tw = tab_w - 2 390 | 391 | Label(title, align="center").draw(window, tw, 1, x + i * tab_w, y, 392 | tab_color) 393 | 394 | self.widgets[self.selected].draw(window, w, h - 2, x, y + 2) 395 | 396 | def on_keypress(self, key): 397 | should_continue = self.widgets[self.selected].on_keypress(key) 398 | if should_continue: 399 | return super().on_keypress(key) 400 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # flake8: noqa 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | import distribute_setup 8 | distribute_setup.use_setuptools() 9 | from setuptools import setup 10 | 11 | setup( 12 | name='pyvidctrl', 13 | version='1.0.1', 14 | author='Antmicro Ltd', 15 | description="Simple TUI to control V4L2 cameras", 16 | author_email='contact@antmicro.com', 17 | url='https://github.com/antmicro/pyvidctrl', 18 | license='Apache Software License (http://www.apache.org/licenses/LICENSE-2.0)', 19 | packages=['pyvidctrl'], 20 | entry_points={ 21 | 'console_scripts': [ 22 | 'pyvidctrl = pyvidctrl.__main__:main', 23 | ]}, 24 | install_requires=[ 25 | 'v4l2 @ git+https://github.com/antmicro/python3-v4l2', 26 | ], 27 | classifiers=[ 28 | 'Environment :: Console', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: Apache Software License', 31 | 'Operating System :: Linux', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.6', 34 | 'Topic :: Utilities', 35 | ], 36 | ) 37 | --------------------------------------------------------------------------------