From 00816be8d3858c2c0fbaee2cb89578e29f9c55b8 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sun, 7 May 2023 15:26:58 +0200 Subject: [PATCH 01/14] Remove reference to controls when closing the device, they wouldn't work anyway. --- v4l2py/device.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/v4l2py/device.py b/v4l2py/device.py index a897317..b83e9bc 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): From 43ae912e1d5680c1abeba72652cb5b13f1133d09 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sun, 7 May 2023 22:29:45 +0200 Subject: [PATCH 02/14] BooleanControl: convert value to lower-case before trying to coerce it --- v4l2py/device.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/v4l2py/device.py b/v4l2py/device.py index b83e9bc..05a29f1 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -1160,9 +1160,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: From 4c2024e8f9186bc50077cc3cfd2e7f1ec9173879 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 05:24:18 +0200 Subject: [PATCH 03/14] Initial implementation of configuration file support as per otaku42/v4l2py#10. --- v4l2py/device.py | 113 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/v4l2py/device.py b/v4l2py/device.py index 05a29f1..ced495d 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -17,6 +17,7 @@ import os import pathlib import typing +import configparser from io import IOBase from collections import UserDict @@ -1266,6 +1267,118 @@ def decrease(self, steps: int = 1): self.value -= steps * self.step +class ConfigManager: + def __init__(self, device: Device): + self.device = device + self.log = log.getChild(f"config({device.index})") + 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) + } + 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 RuntimeError(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 RuntimeError(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 apply(self, cycles: int = 2) -> None: + self.log.info("applying configuration") + if not self.config_loaded: + raise RuntimeError("Load configuration first") + + 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") + + 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}: should: {value}, is: {cur}") + if not cur.lower() == value.lower(): + raise RuntimeError(f"{ctrl} should be {value}, but is {cur}") + else: + self.log.debug(f"{ctrl} skipped (not readable)") + self.log.info("device is configured correctly") + + class DeviceHelper: def __init__(self, device: Device): super().__init__() From 8be4a26efb4c8ec9425e7eddc97e17e5bc4953ad Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 21:59:26 +0200 Subject: [PATCH 04/14] Controls: add method named_keys, which returns the config_name of each control --- v4l2py/device.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/v4l2py/device.py b/v4l2py/device.py index ced495d..4b19494 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -835,6 +835,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)} From 3434df8eec7d6e1323e9e3484ac5a7748c48f0d8 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 22:03:47 +0200 Subject: [PATCH 05/14] ConfigManager: add method validate, which tries to assert as good as possible that the loaded configuration can be applied to the given devide. --- v4l2py/device.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/v4l2py/device.py b/v4l2py/device.py index 4b19494..09c2633 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -1347,6 +1347,31 @@ def load(self, filename) -> None: 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 RuntimeError(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 RuntimeError(f"{self.device.filename} has no control named {ctrl}") + + if pedantic: + if not self.config.has_section("device"): + raise RuntimeError("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)), + ): + want = self.config["device"][option] + if not (want == have): + raise RuntimeError(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: @@ -1374,7 +1399,7 @@ def verify(self) -> None: 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}: should: {value}, is: {cur}") + self.log.debug(f"{ctrl}: want {value}, have {cur}") if not cur.lower() == value.lower(): raise RuntimeError(f"{ctrl} should be {value}, but is {cur}") else: From b164e12c4edd323235b10d93b135074efdd01a89 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 22:18:37 +0200 Subject: [PATCH 06/14] Move ConfigManager to config.py --- v4l2py/config.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++ v4l2py/device.py | 138 ------------------------------------------- 2 files changed, 150 insertions(+), 138 deletions(-) create mode 100644 v4l2py/config.py diff --git a/v4l2py/config.py b/v4l2py/config.py new file mode 100644 index 0000000..04a2b00 --- /dev/null +++ b/v4l2py/config.py @@ -0,0 +1,150 @@ +# +# 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 + +log = logging.getLogger(__name__) + + +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) + } + 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 RuntimeError(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 RuntimeError(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 RuntimeError(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 RuntimeError(f"{self.device.filename} has no control named {ctrl}") + + if pedantic: + if not self.config.has_section("device"): + raise RuntimeError("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)), + ): + want = self.config["device"][option] + if not (want == have): + raise RuntimeError(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") + + 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") + + 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 RuntimeError(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 09c2633..a8472d0 100644 --- a/v4l2py/device.py +++ b/v4l2py/device.py @@ -17,7 +17,6 @@ import os import pathlib import typing -import configparser from io import IOBase from collections import UserDict @@ -1270,143 +1269,6 @@ def decrease(self, steps: int = 1): self.value -= steps * self.step -class ConfigManager: - def __init__(self, device: Device): - self.device = device - self.log = log.getChild(f"config({device.index})") - 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) - } - 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 RuntimeError(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 RuntimeError(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 RuntimeError(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 RuntimeError(f"{self.device.filename} has no control named {ctrl}") - - if pedantic: - if not self.config.has_section("device"): - raise RuntimeError("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)), - ): - want = self.config["device"][option] - if not (want == have): - raise RuntimeError(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") - - 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") - - 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 RuntimeError(f"{ctrl} should be {value}, but is {cur}") - else: - self.log.debug(f"{ctrl} skipped (not readable)") - self.log.info("device is configured correctly") - - class DeviceHelper: def __init__(self, device: Device): super().__init__() From 0c6617ded03e394c8018a232680c41a5308e1dbe Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 22:50:41 +0200 Subject: [PATCH 07/14] Add and make use of more differentiated exceptions --- v4l2py/config.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/v4l2py/config.py b/v4l2py/config.py index 04a2b00..a33001b 100644 --- a/v4l2py/config.py +++ b/v4l2py/config.py @@ -8,11 +8,23 @@ import pathlib import configparser -from .device import Device +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 @@ -58,7 +70,7 @@ def save(self, filename) -> None: raise TypeError(f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}") if self.device.closed: - raise RuntimeError(f"{self.device}: must be opened to save configuration") + raise V4L2Error(f"{self.device} must be opened to save configuration") if not self.config or not self.config.sections(): self.acquire() @@ -79,7 +91,7 @@ def load(self, filename) -> None: if not (filename.exists() and filename.is_file()): raise RuntimeError(f"{filename} must be an existing file") if self.device.closed: - raise RuntimeError(f"{self.device}: must be opened to load configuration") + raise V4L2Error(f"{self.device} must be opened to load configuration") self.reset() res = self.config.read((filename,)) @@ -96,15 +108,15 @@ def validate(self, pedantic: bool = False) -> None: raise RuntimeError("Load configuration first") for section in ("controls",): if not self.config.has_section(section): - raise RuntimeError(f"Mandatory section '{section}' is missing") + 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 RuntimeError(f"{self.device.filename} has no control named {ctrl}") + raise CompatibilityError(f"{self.device.filename} has no control named {ctrl}") if pedantic: if not self.config.has_section("device"): - raise RuntimeError("Section 'device' is missing") + raise ConfigurationError("Section 'device' is missing") for (option, have) in ( ("card", str(self.device.info.card)), ("driver", str(self.device.info.driver)), @@ -112,13 +124,15 @@ def validate(self, pedantic: bool = False) -> None: ): want = self.config["device"][option] if not (want == have): - raise RuntimeError(f"{option.title()} mismatch: want '{want}', have '{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"): @@ -138,13 +152,15 @@ 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 RuntimeError(f"{ctrl} should be {value}, but is {cur}") + 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") From 57f931e5126ba760dba75b91c076b2925f8cdd37 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 23:00:33 +0200 Subject: [PATCH 08/14] Trivial cosmetics --- v4l2py/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v4l2py/config.py b/v4l2py/config.py index a33001b..6970944 100644 --- a/v4l2py/config.py +++ b/v4l2py/config.py @@ -80,7 +80,6 @@ def save(self, filename) -> None: def load(self, filename) -> None: self.log.info(f"reading configuration from {filename}") - if isinstance(filename, pathlib.Path): pass elif isinstance(filename, str): @@ -106,6 +105,7 @@ 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") From 19e84ee2ea670aa1258e6b88a4af3d5b987cfc7a Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Thu, 11 May 2023 23:50:09 +0200 Subject: [PATCH 09/14] Take legacy_controls status into account, too --- v4l2py/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/v4l2py/config.py b/v4l2py/config.py index 6970944..af30ffb 100644 --- a/v4l2py/config.py +++ b/v4l2py/config.py @@ -53,7 +53,8 @@ def acquire(self) -> None: "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) + "version": str(self.device.info.version), + "legacy_controls": str(self.device.legacy_controls), } self.config["controls"] = {} for c in self.device.controls.values(): @@ -121,6 +122,7 @@ def validate(self, pedantic: bool = False) -> None: ("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): From 7d8ef1d325cfbaa0589c4a356eedcfe3faf18913 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Fri, 12 May 2023 00:30:08 +0200 Subject: [PATCH 10/14] Extend v4l2py-ctl to allow for saving and loading configurations --- examples/v4l2py-ctl.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/examples/v4l2py-ctl.py b/examples/v4l2py-ctl.py index 9bbcb09..b5b97ce 100644 --- a/examples/v4l2py-ctl.py +++ b/examples/v4l2py-ctl.py @@ -1,6 +1,8 @@ import argparse +import pathlib from v4l2py.device import Device, MenuControl, LegacyControl +from v4l2py.config import ConfigManager def _get_ctrl(cam, control): @@ -126,6 +128,40 @@ 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(",")] @@ -178,6 +214,28 @@ def csv(string: str) -> list: action="store_true", help="reset all controls to their default value", ) + parser.add_argument( + "--save", + type=str, + dest="save_file", + default=None, + metavar="", + help="save current configuration to ", + ) + parser.add_argument( + "--load", + type=str, + dest="load_file", + default=None, + metavar="", + help="load configuration from and apply it to selected device", + ) + parser.add_argument( + "--pedantic", + default=False, + action="store_true", + help="be pedantic when validating a configuration (--load only)" + ) args = parser.parse_args() @@ -194,6 +252,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) From 5894dfb10ea6ef69998248888d828572851f7af4 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sat, 13 May 2023 00:42:48 +0200 Subject: [PATCH 11/14] Allow for listing all available video capture devices --- examples/v4l2py-ctl.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/examples/v4l2py-ctl.py b/examples/v4l2py-ctl.py index b5b97ce..2b020a1 100644 --- a/examples/v4l2py-ctl.py +++ b/examples/v4l2py-ctl.py @@ -2,6 +2,7 @@ import pathlib from v4l2py.device import Device, MenuControl, LegacyControl +from v4l2py.device import iter_video_capture_devices, Capability from v4l2py.config import ConfigManager @@ -19,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") @@ -180,6 +201,12 @@ def csv(string: str) -> list: action="store_true", help="when changing numeric controls, enforce the written value to be within allowed range (default: %(default)s)", ) + parser.add_argument( + "--list-devices", + default=False, + action="store_true", + help="list all video capture devices", + ) parser.add_argument( "--device", type=str, @@ -244,7 +271,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) From dfec3fb6a9cdfe5f88c8ede156df1cd5b2433598 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sat, 13 May 2023 01:06:38 +0200 Subject: [PATCH 12/14] Improve help text --- examples/v4l2py-ctl.py | 52 ++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/examples/v4l2py-ctl.py b/examples/v4l2py-ctl.py index 2b020a1..dc25f34 100644 --- a/examples/v4l2py-ctl.py +++ b/examples/v4l2py-ctl.py @@ -188,60 +188,74 @@ def csv(string: str) -> list: 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( + flags.add_argument( + "--pedantic", + default=False, + action="store_true", + help="be pedantic when validating a loaded configuration (default: %(default)s)" + ) + + actions = parser.add_argument_group("Actions") + actions.add_argument( "--list-devices", default=False, action="store_true", help="list all video capture devices", ) - 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", - ) - parser.add_argument( + 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", ) - parser.add_argument( + actions.add_argument( "--save", type=str, dest="save_file", @@ -249,7 +263,7 @@ def csv(string: str) -> list: metavar="", help="save current configuration to ", ) - parser.add_argument( + actions.add_argument( "--load", type=str, dest="load_file", @@ -257,12 +271,6 @@ def csv(string: str) -> list: metavar="", help="load configuration from and apply it to selected device", ) - parser.add_argument( - "--pedantic", - default=False, - action="store_true", - help="be pedantic when validating a configuration (--load only)" - ) args = parser.parse_args() From e20e852b5f468715675db116180867a488593cd2 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sun, 14 May 2023 00:06:10 +0200 Subject: [PATCH 13/14] Document configuration file support in README.md. That's it for now, and this also closes #10. --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d778a4d..1364fb2 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, From 567b7c96ff646120fb0a17fb3eed422c938260c1 Mon Sep 17 00:00:00 2001 From: Michael Renzmann Date: Sun, 14 May 2023 01:24:39 +0200 Subject: [PATCH 14/14] Apply some black magic --- examples/v4l2py-ctl.py | 14 ++++++++++---- v4l2py/config.py | 23 +++++++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/examples/v4l2py-ctl.py b/examples/v4l2py-ctl.py index dc25f34..4e08160 100644 --- a/examples/v4l2py-ctl.py +++ b/examples/v4l2py-ctl.py @@ -155,7 +155,9 @@ def save_to_file(device: str, legacy_controls: bool, filename) -> None: elif isinstance(filename, str): filename = pathlib.Path(filename) else: - raise TypeError(f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}") + 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()}") @@ -165,13 +167,17 @@ def save_to_file(device: str, legacy_controls: bool, filename) -> None: print("") -def load_from_file(device: str, legacy_controls: bool, filename, pedantic: bool) -> None: +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__}") + 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()}") @@ -218,7 +224,7 @@ def csv(string: str) -> list: "--pedantic", default=False, action="store_true", - help="be pedantic when validating a loaded configuration (default: %(default)s)" + help="be pedantic when validating a loaded configuration (default: %(default)s)", ) actions = parser.add_argument_group("Actions") diff --git a/v4l2py/config.py b/v4l2py/config.py index af30ffb..56462da 100644 --- a/v4l2py/config.py +++ b/v4l2py/config.py @@ -34,7 +34,10 @@ def __init__(self, device: Device): @property def has_config(self) -> bool: - return isinstance(self.config, configparser.ConfigParser) and self.config.sections() + return ( + isinstance(self.config, configparser.ConfigParser) + and self.config.sections() + ) @property def config_loaded(self) -> bool: @@ -68,7 +71,9 @@ def save(self, filename) -> None: elif isinstance(filename, str): filename = pathlib.Path(filename) else: - raise TypeError(f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}") + 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") @@ -86,7 +91,9 @@ def load(self, filename) -> None: elif isinstance(filename, str): filename = pathlib.Path(filename) else: - raise TypeError(f"filename expected to be str or pathlib.Path, not {filename.__class__.__name__}") + 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") @@ -113,12 +120,14 @@ def validate(self, pedantic: bool = False) -> None: 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}") + 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 ( + for option, have in ( ("card", str(self.device.info.card)), ("driver", str(self.device.info.driver)), ("version", str(self.device.info.version)), @@ -126,7 +135,9 @@ def validate(self, pedantic: bool = False) -> None: ): want = self.config["device"][option] if not (want == have): - raise CompatibilityError(f"{option.title()} mismatch: want '{want}', have '{have}'") + raise CompatibilityError( + f"{option.title()} mismatch: want '{want}', have '{have}'" + ) self.log.info("configuration validated") def apply(self, cycles: int = 2) -> None: