diff --git a/README.md b/README.md index c3a2301..f7a3d01 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Format(width=640, height=480, pixelformat=} v4l2py is asyncio friendly: -```bash +```python $ python -m asyncio >>> from v4l2py import Device @@ -142,7 +142,7 @@ frame 10136 v4l2py is also gevent friendly: -``` +```python $ python >>> from v4l2py import Device, GeventIO @@ -158,6 +158,78 @@ frame 10136 (check [basic gevent](examples/basic_gevent.py) and [web gevent](examples/web/sync.py) examples) +## Configuration files + +v4l2py now supports configuration files, allowing to save the current settings +(controls only at this time) of a device to a file: + +```python +from v4l2py import Device +from v4l2py.config import ConfigManager + +with Device.from_id(0) as cam: + cfg = ConfigManager(cam) + cfg.acquire() + cfg.save("cam.ini") +... +``` + +The configuration is written to an ini-style file, which might look like this: + +```dosini +[device] +driver = uvcvideo +card = Integrated Camera: Integrated C +bus_info = usb-0000:00:14.0-8 +version = 6.1.15 +legacy_controls = False + +[controls] +brightness = 128 +contrast = 32 +saturation = 64 +hue = 0 +white_balance_automatic = True +... +``` + +When loading a configuration file, the content may be validated to ensure it +fits the device it's going to be applied to, and after applying the +configuration it can be verified that the device is in the state that the +configuration file describes: + +```python +from v4l2py import Device +from v4l2py.config import ConfigManager + +with Device.from_id(0) as cam: + cfg = ConfigManager(cam) + cfg.load("cam.ini") + cfg.validate(pedantic=True) + cfg.apply() + cfg.verify() +``` + +[v4l2py-ctl](examples/v4l2py-ctl.py) can be used for that purpose, too: + +```bash +$ python v4l2py-ctl.py --device /dev/video2 --reset-all +Resetting all controls to default ... + +Done. +$ python v4l2py-ctl.py --device /dev/video2 --save cam-defaults.ini +Saving device configuration to /home/mrenzmann/src/v4l2py-o42/cam-defaults.ini + +Done. +$ +$ # ... after messing around with the controls ... +$ python v4l2py-ctl.py --device /dev/video2 --load cam-defaults.ini +Loading device configuration from /home/mrenzmann/src/v4l2py-o42/cam-defaults.ini + +Done. +$ +``` + ## Bonus track You've been patient enough to read until here so, just for you, diff --git a/examples/v4l2py-ctl.py b/examples/v4l2py-ctl.py index 9bbcb09..4e08160 100644 --- a/examples/v4l2py-ctl.py +++ b/examples/v4l2py-ctl.py @@ -1,6 +1,9 @@ import argparse +import pathlib from v4l2py.device import Device, MenuControl, LegacyControl +from v4l2py.device import iter_video_capture_devices, Capability +from v4l2py.config import ConfigManager def _get_ctrl(cam, control): @@ -17,6 +20,26 @@ def _get_ctrl(cam, control): return ctrl +def list_devices() -> None: + print("Listing all video capture devices ...\n") + for dev in iter_video_capture_devices(): + with dev as cam: + print(f"{cam.index:>2}: {cam.info.card}") + print(f"\tdriver : {cam.info.driver}") + print(f"\tversion : {cam.info.version}") + print(f"\tbus : {cam.info.bus_info}") + caps = [ + cap.name.lower() + for cap in Capability + if ((cam.info.device_capabilities & cap) == cap) + ] + if caps: + print("\tcaps :", ", ".join(caps)) + else: + print("\tcaps : none") + print() + + def show_control_status(device: str, legacy_controls: bool) -> None: with Device(device, legacy_controls=legacy_controls) as cam: print("Showing current status of all controls ...\n") @@ -126,58 +149,134 @@ def reset_all_controls(device: str, legacy_controls: bool) -> None: cam.controls.set_to_default() +def save_to_file(device: str, legacy_controls: bool, filename) -> None: + if isinstance(filename, pathlib.Path): + pass + elif isinstance(filename, str): + filename = pathlib.Path(filename) + else: + raise TypeError( + f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}" + ) + + with Device(device, legacy_controls) as cam: + print(f"Saving device configuration to {filename.resolve()}") + cfg = ConfigManager(cam) + cfg.acquire() + cfg.save(filename) + print("") + + +def load_from_file( + device: str, legacy_controls: bool, filename, pedantic: bool +) -> None: + if isinstance(filename, pathlib.Path): + pass + elif isinstance(filename, str): + filename = pathlib.Path(filename) + else: + raise TypeError( + f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}" + ) + + with Device(device, legacy_controls) as cam: + print(f"Loading device configuration from {filename.resolve()}") + cfg = ConfigManager(cam) + cfg.load(filename) + cfg.validate(pedantic=pedantic) + cfg.apply() + cfg.verify() + print("") + + def csv(string: str) -> list: return [v.strip() for v in string.split(",")] if __name__ == "__main__": - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + prog="v4l2py-ctl", + description="Example utility to control video capture devices.", + epilog="When no action is given, the control status of the selected device is shown.", + ) parser.add_argument( + "--device", + type=str, + default="0", + metavar="", + help="use device instead of /dev/video0; if starts with a digit, then /dev/video is used", + ) + + flags = parser.add_argument_group("Flags") + flags.add_argument( "--legacy", default=False, action="store_true", help="use legacy controls (default: %(default)s)", ) - parser.add_argument( + flags.add_argument( "--clipping", default=False, action="store_true", help="when changing numeric controls, enforce the written value to be within allowed range (default: %(default)s)", ) - parser.add_argument( - "--device", - type=str, - default="0", - metavar="", - help="use device instead of /dev/video0; if starts with a digit, then /dev/video is used", + flags.add_argument( + "--pedantic", + default=False, + action="store_true", + help="be pedantic when validating a loaded configuration (default: %(default)s)", ) - parser.add_argument( + + actions = parser.add_argument_group("Actions") + actions.add_argument( + "--list-devices", + default=False, + action="store_true", + help="list all video capture devices", + ) + actions.add_argument( "--get-ctrl", type=csv, default=[], metavar="[,...]", help="get the values of the specified controls", ) - parser.add_argument( + actions.add_argument( "--set-ctrl", type=csv, default=[], metavar="=[,=...]", help="set the values of the specified controls", ) - parser.add_argument( + actions.add_argument( "--reset-ctrl", type=csv, default=[], metavar="[,...]", help="reset the specified controls to their default values", ) - parser.add_argument( + actions.add_argument( "--reset-all", default=False, action="store_true", help="reset all controls to their default value", ) + actions.add_argument( + "--save", + type=str, + dest="save_file", + default=None, + metavar="", + help="save current configuration to ", + ) + actions.add_argument( + "--load", + type=str, + dest="load_file", + default=None, + metavar="", + help="load configuration from and apply it to selected device", + ) args = parser.parse_args() @@ -186,7 +285,9 @@ def csv(string: str) -> list: else: dev = args.device - if args.reset_all: + if args.list_devices: + list_devices() + elif args.reset_all: reset_all_controls(dev, args.legacy) elif args.reset_ctrl: reset_controls(dev, args.reset_ctrl, args.legacy) @@ -194,6 +295,10 @@ def csv(string: str) -> list: get_controls(dev, args.get_ctrl, args.legacy) elif args.set_ctrl: set_controls(dev, args.set_ctrl, args.legacy, args.clipping) + elif args.save_file is not None: + save_to_file(dev, args.legacy, args.save_file) + elif args.load_file is not None: + load_from_file(dev, args.legacy, args.load_file, args.pedantic) else: show_control_status(dev, args.legacy) diff --git a/v4l2py/config.py b/v4l2py/config.py new file mode 100644 index 0000000..56462da --- /dev/null +++ b/v4l2py/config.py @@ -0,0 +1,179 @@ +# +# This file is part of the v4l2py project +# +# Copyright (c) 2021 Tiago Coutinho +# Distributed under the GPLv3 license. See LICENSE for more info. + +import logging +import pathlib +import configparser + +from .device import Device, V4L2Error + +log = logging.getLogger(__name__) + + +class ConfigurationError(V4L2Error): + pass + + +class CompatibilityError(V4L2Error): + pass + + +class DeviceStateError(V4L2Error): + pass + + +class ConfigManager: + def __init__(self, device: Device): + self.device = device + self.log = log.getChild(f"{device.filename.stem}") + self.config = None + self.filename = None + + @property + def has_config(self) -> bool: + return ( + isinstance(self.config, configparser.ConfigParser) + and self.config.sections() + ) + + @property + def config_loaded(self) -> bool: + return self.filename is not None + + def reset(self) -> None: + self.config = configparser.ConfigParser() + self.filename = None + + def acquire(self) -> None: + self.log.info(f"acquiring configuration from {self.device.filename}") + if not self.has_config: + self.reset() + + self.config["device"] = { + "driver": str(self.device.info.driver), + "card": str(self.device.info.card), + "bus_info": str(self.device.info.bus_info), + "version": str(self.device.info.version), + "legacy_controls": str(self.device.legacy_controls), + } + self.config["controls"] = {} + for c in self.device.controls.values(): + self.config["controls"][c.config_name] = str(c.value) + self.log.info("configuration successfully acquired") + + def save(self, filename) -> None: + self.log.info(f"writing configuration to {filename}") + if isinstance(filename, pathlib.Path): + pass + elif isinstance(filename, str): + filename = pathlib.Path(filename) + else: + raise TypeError( + f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}" + ) + + if self.device.closed: + raise V4L2Error(f"{self.device} must be opened to save configuration") + if not self.config or not self.config.sections(): + self.acquire() + + with filename.open(mode="wt") as configfile: + self.config.write(configfile) + self.log.info(f"configuration written to {filename.resolve()}") + + def load(self, filename) -> None: + self.log.info(f"reading configuration from {filename}") + if isinstance(filename, pathlib.Path): + pass + elif isinstance(filename, str): + filename = pathlib.Path(filename) + else: + raise TypeError( + f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}" + ) + + if not (filename.exists() and filename.is_file()): + raise RuntimeError(f"{filename} must be an existing file") + if self.device.closed: + raise V4L2Error(f"{self.device} must be opened to load configuration") + + self.reset() + res = self.config.read((filename,)) + if not res: + raise RuntimeError(f"Failed to read configuration from {filename}") + else: + filename = pathlib.Path(res[0]) + self.filename = filename.resolve() + self.log.info(f"configuration read from {self.filename}") + + def validate(self, pedantic: bool = False) -> None: + self.log.info("validating configuration") + if not self.config_loaded: + raise RuntimeError("Load configuration first") + + for section in ("controls",): + if not self.config.has_section(section): + raise ConfigurationError(f"Mandatory section '{section}' is missing") + controls = self.device.controls.named_keys() + for ctrl, _ in self.config.items("controls"): + if ctrl not in controls: + raise CompatibilityError( + f"{self.device.filename} has no control named {ctrl}" + ) + + if pedantic: + if not self.config.has_section("device"): + raise ConfigurationError("Section 'device' is missing") + for option, have in ( + ("card", str(self.device.info.card)), + ("driver", str(self.device.info.driver)), + ("version", str(self.device.info.version)), + ("legacy_controls", str(self.device.legacy_controls)), + ): + want = self.config["device"][option] + if not (want == have): + raise CompatibilityError( + f"{option.title()} mismatch: want '{want}', have '{have}'" + ) + self.log.info("configuration validated") + + def apply(self, cycles: int = 2) -> None: + self.log.info("applying configuration") + if not self.config_loaded: + raise RuntimeError("Load configuration first") + if self.device.closed: + raise V4L2Error(f"{self.device} must be opened to apply configuration") + + for cycle in range(1, cycles + 1): + for ctrl, value in self.config.items("controls"): + what = f"#{cycle}/{cycles} {ctrl}" + if self.device.controls[ctrl].is_writeable: + if not self.device.controls[ctrl].is_flagged_write_only: + cur = f"{self.device.controls[ctrl].value}" + else: + cur = "" + self.log.debug(f"{what} {cur} => {value}") + self.device.controls[ctrl].value = value + else: + self.log.debug(f"{what} skipped (not writeable)") + self.log.info("configuration applied") + + def verify(self) -> None: + self.log.info("verifying device configuration") + if not self.config_loaded: + raise RuntimeError("Load configuration first") + if self.device.closed: + raise V4L2Error(f"{self.device} must be opened to verify configuration") + + for ctrl, value in self.config.items("controls"): + if not self.device.controls[ctrl].is_flagged_write_only: + cur = str(self.device.controls[ctrl].value) + self.log.debug(f"{ctrl}: want {value}, have {cur}") + if not cur.lower() == value.lower(): + raise DeviceStateError(f"{ctrl} should be {value}, but is {cur}") + else: + self.log.debug(f"{ctrl} skipped (not readable)") + self.log.info("device is configured correctly") diff --git a/v4l2py/device.py b/v4l2py/device.py index a897317..a8472d0 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -684,6 +684,9 @@ def _init(self): else: self.controls = Controls.from_device(self) + def _reset(self): + self.controls = None + def open(self): if not self._fobj: self.log.info("opening %s", self.filename) @@ -696,6 +699,7 @@ def close(self): self.log.info("closing %s (%s)", self.filename, self.info.card) self._fobj.close() self._fobj = None + self._reset() self.log.info("closed %s (%s)", self.filename, self.info.card) def fileno(self): @@ -830,6 +834,9 @@ def __missing__(self, key): return v raise KeyError(key) + def named_keys(self): + return [v.config_name for v in self.values() if isinstance(v, BaseControl)] + def used_classes(self): return {v.control_class for v in self.values() if isinstance(v, BaseControl)} @@ -1156,9 +1163,10 @@ def _convert_write(self, value): if isinstance(value, bool): return value elif isinstance(value, str): - if value in self._true: + v = value.lower() + if v in self._true: return True - elif value in self._false: + elif v in self._false: return False else: try: