diff --git a/.gitignore b/.gitignore index e4c2fd4f..022a85b7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ leftover* # tmp file workaround lost* -# ignore bin paths +# Ignore bin paths bin/ustreamer bin/camera-streamer @@ -30,3 +30,6 @@ tools/.config # Ignore pkglist tools/pkglist.sh + +# Ignore pycache +**/__pycache__/ diff --git a/crowsnest b/crowsnest index 39669681..7d203f49 100755 --- a/crowsnest +++ b/crowsnest @@ -19,19 +19,19 @@ set -Ee # Base Path BASE_CN_PATH="$(dirname "$(readlink -f "${0}")")" -## Import Librarys -# shellcheck source-path=SCRIPTDIR/../libs/ -. "${BASE_CN_PATH}/libs/camera-streamer.sh" -. "${BASE_CN_PATH}/libs/configparser.sh" -. "${BASE_CN_PATH}/libs/core.sh" -. "${BASE_CN_PATH}/libs/hwhandler.sh" -. "${BASE_CN_PATH}/libs/init_stream.sh" -. "${BASE_CN_PATH}/libs/logging.sh" -. "${BASE_CN_PATH}/libs/messages.sh" -. "${BASE_CN_PATH}/libs/ustreamer.sh" -. "${BASE_CN_PATH}/libs/v4l2_control.sh" -. "${BASE_CN_PATH}/libs/versioncontrol.sh" -. "${BASE_CN_PATH}/libs/watchdog.sh" +function missing_args_msg { + echo -e "crowsnest: Missing Arguments!" + echo -e "\n\tTry: crowsnest -h\n" +} + +function check_cfg { + if [ ! -r "${1}" ]; then + log_msg "ERROR: No Configuration File found. Exiting!" + exit 1 + else + return 0 + fi +} #### MAIN ## Args given? @@ -74,16 +74,25 @@ while getopts ":vhc:s:d" arg; do esac done -init_logging -initial_check -construct_streamer +function set_log_path { + #Workaround sed ~ to BASH VAR $HOME + CROWSNEST_LOG_PATH=$(get_param "crowsnest" log_path | sed "s#^~#${HOME}#gi") + declare -g CROWSNEST_LOG_PATH + #Workaround: Make Dir if not exist + if [ ! -d "$(dirname "${CROWSNEST_LOG_PATH}")" ]; then + mkdir -p "$(dirname "${CROWSNEST_LOG_PATH}")" + fi +} -## Loop and Watchdog -## In this case watchdog acts more like a "cable defect detector" -## The User gets a message if Device is lost. -clean_watchdog -while true ; do - crowsnest_watchdog - sleep 120 & sleep_pid="$!" - wait "${sleep_pid}" -done +function get_param { + local cfg section param + cfg="${CROWSNEST_CFG}" + section="${1}" + param="${2}" + crudini --get "${cfg}" "${section}" "${param}" 2> /dev/null | \ + sed 's/\#.*//;s/[[:space:]]*$//' + return +} + +set_log_path +python3 "${BASE_CN_PATH}/crowsnest.py" -c "${CROWSNEST_CFG}" -l "${CROWSNEST_LOG_PATH}" diff --git a/crowsnest.py b/crowsnest.py new file mode 100644 index 00000000..8419fa10 --- /dev/null +++ b/crowsnest.py @@ -0,0 +1,134 @@ +#!/usr/bin/python3 + +import argparse +import configparser +import asyncio +import signal +import traceback + +from pylibs.components.crowsnest import Crowsnest +from pylibs import utils, watchdog, logger, logging_helper + +parser = argparse.ArgumentParser( + prog='Crowsnest', + description='Crowsnest - A webcam daemon for Raspberry Pi OS distributions like MainsailOS' +) +config = configparser.ConfigParser(inline_comment_prefixes='#') + +parser.add_argument('-c', '--config', help='Path to config file', required=True) +parser.add_argument('-l', '--log_path', help='Path to log file', required=True) + +args = parser.parse_args() + +watchdog_running = True + +def initial_parse_config(): + global crowsnest, config, args + config_path = args.config + try: + config.read(config_path) + except configparser.ParsingError as e: + logger.log_multiline(e.message, logger.log_error) + logger.log_error("Failed to parse config! Exiting...") + exit(1) + crowsnest = Crowsnest('crowsnest') + if not config.has_section('crowsnest') or not crowsnest.parse_config_section(config['crowsnest']): + logger.log_error("Failed to parse config for '[crowsnest]' section! Exiting...") + exit(1) + +async def start_sections(): + global config, sect_exec_tasks + sect_objs = [] + sect_exec_tasks = set() + + # Catches SIGINT and SIGTERM to exit gracefully and cancel all tasks + signal.signal(signal.SIGINT, exit_gracefully) + signal.signal(signal.SIGTERM, exit_gracefully) + + try: + if not len(config.sections()) > 1: + logger.log_quiet("No Cams / Services to start! Exiting ...") + return + logger.log_quiet("Try to parse configured Cams / Services...") + for section in config.sections(): + section_header = section.split(' ') + section_object = None + section_keyword = section_header[0] + + # Skip crowsnest section + if section_keyword == 'crowsnest': + continue + + section_name = ' '.join(section_header[1:]) + component = utils.load_component(section_keyword, section_name) + logger.log_quiet(f"Parse configuration of section [{section}] ...") + if component.parse_config_section(config[section]): + sect_objs.append(component) + logger.log_quiet(f"Configuration of section [{section}] looks good. Continue ...") + else: + logger.log_error(f"Failed to parse config for section [{section}]! Skipping ...") + + logger.log_quiet("Try to start configured Cams / Services ...") + if sect_objs: + lock = asyncio.Lock() + for section_object in sect_objs: + task = asyncio.create_task(section_object.execute(lock)) + sect_exec_tasks.add(task) + + # Lets sect_exec_tasks finish first + await asyncio.sleep(0) + async with lock: + logger.log_quiet("... Done!") + else: + logger.log_quiet("No Service started! Exiting ...") + + for task in sect_exec_tasks: + if task is not None: + await task + except Exception as e: + logger.log_multiline(traceback.format_exc().strip(), logger.log_error) + finally: + for task in sect_exec_tasks: + if task is not None: + task.cancel() + watchdog.running = False + logger.log_quiet("Shutdown or Killed by User!") + logger.log_quiet("Please come again :)") + logger.log_quiet("Goodbye...") + +async def exit_gracefully(signum, frame): + asyncio.sleep(1) + +async def main(): + global args, crowsnest + logger.setup_logging(args.log_path) + logging_helper.log_initial() + + initial_parse_config() + + if crowsnest.parameters['delete_log'].value: + logger.logger.handlers.clear() + logger.setup_logging(args.log_path, 'w') + logging_helper.log_initial() + + logger.set_log_level(crowsnest.parameters['log_level'].value) + + logging_helper.log_host_info() + logging_helper.log_streamer() + logging_helper.log_config(args.config) + logging_helper.log_cams() + + task1 = asyncio.create_task(start_sections()) + await asyncio.sleep(0) + task2 = asyncio.create_task(watchdog.run_watchdog()) + + await task1 + if task2: + task2.cancel() + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(main()) + finally: + loop.close() diff --git a/pylibs/__init__.py b/pylibs/__init__.py new file mode 100644 index 00000000..a93a4bf1 --- /dev/null +++ b/pylibs/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/python3 diff --git a/pylibs/camera/__init__.py b/pylibs/camera/__init__.py new file mode 100644 index 00000000..13fd901a --- /dev/null +++ b/pylibs/camera/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/python3 + +from .types.uvc import UVC +from .types.legacy import Legacy +from .types.libcamera import Libcamera +from .camera import Camera +from . import camera_manager diff --git a/pylibs/camera/camera.py b/pylibs/camera/camera.py new file mode 100644 index 00000000..62f9d84c --- /dev/null +++ b/pylibs/camera/camera.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 + +import os +from abc import ABC, abstractmethod + +class Camera(ABC): + def __init__(self, path: str, *args, **kwargs) -> None: + self.path = path + self.control_values = {} + self.formats = {} + + def path_equals(self, path: str) -> bool: + return self.path == os.path.realpath(path) + + @abstractmethod + def get_formats_string(self) -> str: + pass + + @abstractmethod + def get_controls_string(self) -> str: + pass + + @staticmethod + @abstractmethod + def init_camera_type() -> list: + pass diff --git a/pylibs/camera/camera_manager.py b/pylibs/camera/camera_manager.py new file mode 100644 index 00000000..3827344e --- /dev/null +++ b/pylibs/camera/camera_manager.py @@ -0,0 +1,24 @@ +#!/usr/bin/python3 + +from .camera import Camera + +def get_all_cameras() -> list: + global cameras + try: + cameras + except NameError: + cameras = [] + return cameras + +def get_cam_by_path(path: str) -> Camera: + global cameras + for camera in get_all_cameras(): + if camera.path_equals(path): + return camera + return None + +def init_camera_type(obj: Camera) -> list: + global cameras + cams = obj.init_camera_type() + get_all_cameras().extend(cams) + return cams diff --git a/pylibs/camera/types/legacy.py b/pylibs/camera/types/legacy.py new file mode 100644 index 00000000..5dcee791 --- /dev/null +++ b/pylibs/camera/types/legacy.py @@ -0,0 +1,10 @@ +from . import uvc +from ... import v4l2 + +class Legacy(uvc.UVC): + @staticmethod + def init_camera_type() -> list: + legacy_path = v4l2.ctl.get_dev_path_by_name('mmal') + if not legacy_path: + return [] + return [Legacy(legacy_path)] diff --git a/pylibs/camera/types/libcamera.py b/pylibs/camera/types/libcamera.py new file mode 100644 index 00000000..31515cf4 --- /dev/null +++ b/pylibs/camera/types/libcamera.py @@ -0,0 +1,83 @@ +import shutil, re + +from ... import utils +from .. import camera + +class Libcamera(camera.Camera): + def __init__(self, path, *args, **kwargs) -> None: + self.path = path + self.control_values = self._get_controls() + self.formats = [] + + def _get_controls(self) -> str: + ctrls = {} + try: + from libcamera import CameraManager, Rectangle + + def rectangle_to_tuple(rectangle): + return (rectangle.x, rectangle.y, rectangle.width, rectangle.height) + + libcam_cm = CameraManager.singleton() + for cam in libcam_cm.cameras: + if cam.id != self.path: + continue + for k, v in cam.controls.items(): + if isinstance(v.min, Rectangle): + ctrls[k.name] = { + 'min': rectangle_to_tuple(v.min), + 'max': rectangle_to_tuple(v.max), + 'default': rectangle_to_tuple(v.default) + } + else: + ctrls[k.name] = { + 'min': v.min, + 'max': v.max, + 'default': v.default + } + except ImportError: + pass + return ctrls + + def _get_formats(self, libcamera_output: str) -> list: + resolutions = re.findall( + rf"{self.path}.*?:.*?: (.*?)(?=\n\n|\n *')", + libcamera_output, flags=re.DOTALL + ) + res = [] + if resolutions: + res = [r.strip() for r in resolutions[0].split('\n')] + return res + + def get_formats_string(self) -> str: + message = '' + for res in self.formats: + message += f"{res}\n" + return message[:-1] + + def get_controls_string(self) -> str: + if not self.control_values: + return "apt package 'python3-libcamera' is not installed! " \ + "Make sure to install it to log the controls!" + message = '' + for name, value in self.control_values.items(): + min, max, default = value.values() + str_first = f"{name} ({self.get_type_str(min)})" + str_indent = (30 - len(str_first)) * ' ' + ': ' + str_second = f"min={min} max={max} default={default}" + message += str_first + str_indent + str_second + '\n' + return message.strip() + + def get_type_str(self, obj) -> str: + return str(type(obj)).split('\'')[1] + + @staticmethod + def init_camera_type() -> list: + cmd = shutil.which('libcamera-hello') + if not cmd: + return {} + libcam_cmd =f'{cmd} --list-cameras' + libcam = utils.execute_shell_command(libcam_cmd, strip=False) + cams = [Libcamera(path) for path in re.findall(r'\((/base.*?)\)', libcam)] + for cam in cams: + cam.formats = cam._get_formats(libcam) + return cams diff --git a/pylibs/camera/types/uvc.py b/pylibs/camera/types/uvc.py new file mode 100644 index 00000000..5987de4d --- /dev/null +++ b/pylibs/camera/types/uvc.py @@ -0,0 +1,96 @@ +import os + +from .. import camera +from ... import v4l2, logger + +class UVC(camera.Camera): + def __init__(self, path: str, *args, **kwargs) -> None: + self.path_by_path = None + self.path_by_id = None + if path.startswith('/dev/video'): + self.path = path + if kwargs.get('other'): + self.path_by_path = kwargs['other'][0] + self.path_by_id = kwargs['other'][1] + else: + self.path = os.path.realpath(path) + self.path_by_id = path + self.query_controls = v4l2.ctl.get_query_controls(self.path) + + self.control_values = {} + cur_sec = '' + for name, qc in self.query_controls.items(): + parsed_qc = v4l2.ctl.parse_qc_of_path(self.path, qc) + if not parsed_qc: + cur_sec = name + self.control_values[cur_sec] = {} + continue + self.control_values[cur_sec][name] = v4l2.ctl.parse_qc_of_path(self.path, qc) + self.formats = v4l2.ctl.get_formats(self.path) + + + def get_formats_string(self) -> str: + message = '' + indent = ' '*8 + for fmt, data in self.formats.items(): + message += f"{fmt}:\n" + for res, fps_list in data.items(): + message += f"{indent}{res}\n" + for fps in fps_list: + message += f"{indent*2}{fps}\n" + return message[:-1] + + def has_mjpg_hw_encoder(self) -> bool: + for fmt in self.formats.keys(): + if 'Motion-JPEG' in fmt: + return True + return False + + def get_controls_string(self) -> str: + message = '' + for section, controls in self.control_values.items(): + message += f"{section}:\n" + for control, data in controls.items(): + line = f"{control} ({data['type']})" + line += (35 - len(line)) * ' ' + ':' + if data['type'] in ('int'): + line += f" min={data['min']} max={data['max']} step={data['step']}" + line += f" default={data['default']}" + line += f" value={self.get_current_control_value(control)}" + if 'flags' in data: + line += f" flags={data['flags']}" + message += logger.indentation + line + '\n' + if 'menu' in data: + for value, name in data['menu'].items(): + message += logger.indentation*2 + f"{value}: {name}\n" + message += '\n' + return message[:-1] + + def set_control(self, control: str, value: int) -> bool: + return v4l2.ctl.set_control_with_qc(self.path, self.query_controls[control], value) + + def get_current_control_value(self, control: str) -> int: + return v4l2.ctl.get_control_cur_value_with_qc(self.path, self.query_controls[control]) + + @staticmethod + def init_camera_type() -> list: + def get_avail_uvc(search_path): + avail_uvc = {} + if not os.path.exists(search_path): + return avail_uvc + for file in os.listdir(search_path): + dev_path = os.path.join(search_path, file) + if os.path.islink(dev_path) and dev_path.endswith("index0"): + avail_uvc[os.path.realpath(dev_path)] = dev_path + return avail_uvc + + avail_by_id = get_avail_uvc('/dev/v4l/by-id/') + avail_by_path = dict(filter( + lambda key_value_pair: 'usb' in key_value_pair[1], + get_avail_uvc('/dev/v4l/by-path/').items() + ) + ) + avail_uvc_cameras = {} + for dev_path, by_path in avail_by_path.items(): + avail_uvc_cameras[dev_path] = (by_path, avail_by_id.get(dev_path)) + return [UVC(dev_path, other=other_paths) for dev_path,other_paths in avail_uvc_cameras.items()] diff --git a/pylibs/components/cam.py b/pylibs/components/cam.py new file mode 100644 index 00000000..09e88e4e --- /dev/null +++ b/pylibs/components/cam.py @@ -0,0 +1,58 @@ +#!/usr/bin/python3 + +import asyncio +import traceback + +from configparser import SectionProxy +from .section import Section +from .streamer.streamer import Streamer +from ..parameter import Parameter +from .. import logger, utils, watchdog + +class Cam(Section): + section_name = 'cam' + keyword = 'cam' + + def __init__(self, name: str) -> None: + super().__init__(name) + + self.parameters.update({ + 'mode': Parameter(str) + }) + + self.streamer: Streamer = None + + def parse_config_section(self, config_section: SectionProxy, *args, **kwargs) -> bool: + # Dynamically import module + mode = config_section["mode"].split()[0] + self.parameters["mode"].set_value(mode) + self.streamer = utils.load_component(mode, + self.name, + path='pylibs.components.streamer') + if self.streamer is None: + return False + return self.streamer.parse_config_section(config_section, *args, **kwargs) + + async def execute(self, lock: asyncio.Lock): + if self.streamer is None: + print("No streamer loaded") + return + try: + await lock.acquire() + logger.log_quiet( + f"Start {self.streamer.keyword} with device " + f"{self.streamer.parameters['device'].value} ..." + ) + watchdog.configured_devices.append(self.streamer.parameters['device'].value) + process = await self.streamer.execute(lock) + await process.wait() + except Exception as e: + logger.log_multiline(traceback.format_exc().strip(), logger.log_error) + finally: + logger.log_error(f"Start of {self.parameters['mode'].value} [cam {self.name}] failed!") + watchdog.configured_devices.remove(self.streamer.parameters['device'].value) + if lock.locked(): + lock.release() + +def load_component(name: str): + return Cam(name) diff --git a/pylibs/components/crowsnest.py b/pylibs/components/crowsnest.py new file mode 100644 index 00000000..a3780930 --- /dev/null +++ b/pylibs/components/crowsnest.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 + +from .section import Section +from ..parameter import Parameter + +from configparser import SectionProxy +import asyncio + +class Crowsnest(Section): + def __init__(self, name: str = '') -> None: + super().__init__(name) + + self.parameters.update({ + 'log_path': Parameter(str), + 'log_level': Parameter(str, 'verbose'), + 'delete_log': Parameter(bool, 'True'), + 'no_proxy': Parameter(bool, 'False') + }) + + def parse_config_section(self, section: SectionProxy) -> bool: + if not super().parse_config_section(section): + return False + log_level = self.parameters['log_level'].value.lower() + if log_level == 'quiet': + self.parameters['log_level'].value = 'QUIET' + elif log_level == 'debug': + self.parameters['log_level'].value = 'DEBUG' + elif log_level == 'dev': + self.parameters['log_level'].value = 'DEV' + else: + self.parameters['log_level'].value = 'INFO' + return True + + async def execute(self, lock: asyncio.Lock): + pass + + +def load_component(name: str, config_section: SectionProxy, *args, **kwargs): + cn = Crowsnest(name) + if cn.parse_config_section(config_section, *args, **kwargs): + return cn + return None diff --git a/pylibs/components/section.py b/pylibs/components/section.py new file mode 100644 index 00000000..ba8618e7 --- /dev/null +++ b/pylibs/components/section.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 + +import asyncio +from configparser import SectionProxy +from abc import ABC, abstractmethod + +from ..parameter import Parameter +from .. import logger + +class Section(ABC): + section_name = 'section' + keyword = 'section' + available_sections = {} + # Section looks like this: + # [ ] + # param1: value1 + # param2: value2 + def __init__(self, name: str) -> None: + self.name = name + self.parameters: dict[str, Parameter] = {} + + # Parse config according to the needs of the section + def parse_config_section(self, config_section: SectionProxy, *args, **kwargs) -> bool: + success = True + for option, value in config_section.items(): + if option not in self.parameters: + logger.log_warning(f"Parameter '{option}' is not supported by {self.keyword}!") + continue + self.parameters[option].set_value(value) + for option, value in self.parameters.items(): + if value.value is None: + logger.log_error(f"Parameter '{option}' incorrectly set or missing in section " + f"[{self.section_name} {self.name}] but is required!") + success = False + return success + + @abstractmethod + async def execute(self, lock: asyncio.Lock): + pass + +def load_component(*args, **kwargs): + raise NotImplementedError("If you see this, something went wrong!!!") diff --git a/pylibs/components/streamer/camera-streamer.py b/pylibs/components/streamer/camera-streamer.py new file mode 100644 index 00000000..4006c24b --- /dev/null +++ b/pylibs/components/streamer/camera-streamer.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 + +import asyncio + +from .streamer import Streamer +from ...parameter import Parameter +from ... import logger, utils, camera + +class Camera_Streamer(Streamer): + keyword = 'camera-streamer' + + def __init__(self, name: str) -> None: + super().__init__(name) + + self.binary_names = ['camera-streamer'] + self.binary_paths = ['bin/camera-streamer'] + + self.parameters.update({ + 'enable_rtsp': Parameter(bool, 'False'), + 'rtsp_port': Parameter(int, 8554) + }) + + async def execute(self, lock: asyncio.Lock): + if self.parameters['no_proxy'].value: + host = '0.0.0.0' + logger.log_info("Set to 'no_proxy' mode! Using 0.0.0.0!") + else: + host = '127.0.0.1' + port = self.parameters['port'].value + width, height = str(self.parameters['resolution'].value).split('x') + + fps = self.parameters['max_fps'].value + device = self.parameters['device'].value + cam = camera.camera_manager.get_cam_by_path(device) + + streamer_args = [ + '--camera-path=' + device, + # '--http-listen=' + host, + '--http-port=' + str(port), + '--camera-fps=' + str(fps), + '--camera-width=' + width, + '--camera-height=' + height, + '--camera-snapshot.height=' + height, + '--camera-video.height=' + height, + '--camera-stream.height=' + height, + '--camera-auto_reconnect=1' + ] + + v4l2ctl = self.parameters['v4l2ctl'].value + if v4l2ctl: + prefix = "V4L2 Control: " + logger.log_quiet(f"Handling done by {self.keyword}", prefix) + logger.log_quiet(f"Trying to set: {v4l2ctl}", prefix) + for ctrl in v4l2ctl.split(','): + streamer_args += [f'--camera-options={ctrl.strip()}'] + + # if device.startswith('/base') and 'i2c' in device: + if isinstance(cam, camera.Libcamera): + streamer_args += [ + '--camera-type=libcamera', + '--camera-format=YUYV' + ] + # elif device.startswith('/dev/video') or device.startswith('/dev/v4l'): + elif isinstance(cam, (camera.UVC, camera.Legacy)): + streamer_args += [ + '--camera-type=v4l2' + ] + if cam.has_mjpg_hw_encoder(): + streamer_args += [ + '--camera-format=MJPEG' + ] + + if self.parameters['enable_rtsp'].value: + streamer_args += [ + '--rtsp-port=' + str(self.parameters['rtsp_port'].value) + ] + + # custom flags + streamer_args += self.parameters['custom_flags'].value.split() + + cmd = self.binary_path + ' ' + ' '.join(streamer_args) + log_pre = f'{self.keyword} [cam {self.name}]: ' + + logger.log_debug(log_pre + f"Parameters: {' '.join(streamer_args)}") + process,_,_ = await utils.execute_command( + cmd, + info_log_pre=log_pre, + info_log_func=logger.log_debug, + error_log_pre=log_pre, + error_log_func=logger.log_debug + ) + if lock.locked(): + lock.release() + + return process + + +def load_component(name: str): + return Camera_Streamer(name) diff --git a/pylibs/components/streamer/spyglass.py b/pylibs/components/streamer/spyglass.py new file mode 100644 index 00000000..029740ec --- /dev/null +++ b/pylibs/components/streamer/spyglass.py @@ -0,0 +1,70 @@ +#!/usr/bin/python3 + +import asyncio + +from .streamer import Streamer +from ...parameter import Parameter +from ... import logger, utils, camera + +class Spyglass(Streamer): + keyword = 'spyglass' + + def __init__(self, name: str) -> None: + super().__init__(name) + + self.binary_names = ['run.py'] + self.binary_paths = ['bin/spyglass'] + + async def execute(self, lock: asyncio.Lock): + if self.parameters['no_proxy'].value: + host = '0.0.0.0' + logger.log_info("Set to 'no_proxy' mode! Using 0.0.0.0!") + else: + host = '127.0.0.1' + port = self.parameters['port'].value + res = self.parameters['resolution'].value + fps = self.parameters['max_fps'].value + device = self.parameters['device'].value + self.cam = camera.camera_manager.get_cam_by_path(device) + + streamer_args = [ + '--camera_num=' + device, + '--bindaddress=' + host, + '--port=' + str(port), + '--fps=' + str(fps), + '--resolution=' + str(res), + '--stream_url=/?action=stream', + '--snapshot_url=/?action=snapshot', + ] + + v4l2ctl = self.parameters['v4l2ctl'].value + if v4l2ctl: + prefix = "V4L2 Control: " + logger.log_quiet(f"Handling done by {self.keyword}", prefix) + logger.log_quiet(f"Trying to set: {v4l2ctl}", prefix) + for ctrl in v4l2ctl.split(','): + streamer_args += [f'--controls={ctrl.strip()}'] + + # custom flags + streamer_args += self.parameters['custom_flags'].value.split() + + venv_path = self.binary_paths[0]+'/.venv/bin/python3' + cmd = venv_path + ' ' + self.binary_path + ' ' + ' '.join(streamer_args) + log_pre = f'{self.keyword} [cam {self.name}]: ' + + logger.log_debug(log_pre + f"Parameters: {' '.join(streamer_args)}") + process,_,_ = await utils.execute_command( + cmd, + info_log_pre=log_pre, + info_log_func=logger.log_debug, + error_log_pre=log_pre, + error_log_func=logger.log_debug + ) + if lock.locked(): + lock.release() + + return process + + +def load_component(name: str): + return Spyglass(name) diff --git a/pylibs/components/streamer/streamer.py b/pylibs/components/streamer/streamer.py new file mode 100644 index 00000000..c59ef4de --- /dev/null +++ b/pylibs/components/streamer/streamer.py @@ -0,0 +1,88 @@ +#!/usr/bin/python3 + +import textwrap +from configparser import SectionProxy +from abc import ABC +from os import listdir +from os.path import isfile, join + +from ..section import Section +from ...parameter import Parameter +from ... import logger, utils + +class Resolution(): + def __init__(self, value:str) -> None: + try: + self.width, self.height = value.split('x') + except ValueError: + raise ValueError("Custom Error", f"'{value}' is not of format 'x'!") + + def __str__(self) -> str: + return 'x'.join([self.width, self.height]) + +class Streamer(Section, ABC): + section_name = 'cam' + binaries = {} + + def __init__(self, name: str) -> None: + super().__init__(name) + + self.parameters.update({ + 'mode': Parameter(str), + 'port': Parameter(int), + 'device': Parameter(str), + 'resolution': Parameter(Resolution), + 'max_fps': Parameter(int), + 'no_proxy': Parameter(bool, 'False'), + 'custom_flags': Parameter(str, ''), + 'v4l2ctl': Parameter(str, '') + }) + self.binary_names = [] + self.binary_paths = [] + self.binary_path = None + + self.missing_bin_txt = textwrap.dedent("""\ + '%s' executable not found! + Please make sure everything is installed correctly and up to date! + Run 'make update' inside the crowsnest directory to install and update everything.""" + ) + + def parse_config_section(self, config_section: SectionProxy, *args, **kwargs) -> bool: + success = super().parse_config_section(config_section, *args, **kwargs) + if not success: + return False + mode = self.parameters['mode'].value + if mode not in Streamer.binaries: + Streamer.binaries[mode] = utils.get_executable( + self.binary_names, + self.binary_paths + ) + self.binary_path = Streamer.binaries[mode] + + if self.binary_path is None: + logger.log_multiline(self.missing_bin_txt % self.parameters['mode'].value, + logger.log_error) + return False + return True + +def load_all_streamers(): + streamer_path = 'pylibs/components/streamer' + streamer_files = [ + f for f in listdir(streamer_path) + if isfile(join(streamer_path, f)) and f.endswith('.py') + ] + for streamer_file in streamer_files: + streamer_name = streamer_file[:-3] + try: + streamer = utils.load_component(streamer_name, + 'temp', + path=streamer_path.replace('/', '.')) + except NotImplementedError: + continue + Streamer.binaries[streamer_name] = utils.get_executable( + streamer.binary_names, + streamer.binary_paths + ) + +def load_component(name: str): + raise NotImplementedError("If you see this, something went wrong!!!") diff --git a/pylibs/components/streamer/ustreamer.py b/pylibs/components/streamer/ustreamer.py new file mode 100644 index 00000000..d02751a2 --- /dev/null +++ b/pylibs/components/streamer/ustreamer.py @@ -0,0 +1,139 @@ +#!/usr/bin/python3 + +import re +import asyncio + +from .streamer import Streamer +from ... import logger, utils, camera + +class Ustreamer(Streamer): + keyword = 'ustreamer' + + def __init__(self, name: str) -> None: + super().__init__(name) + + self.binary_names = ['ustreamer.bin', 'ustreamer'] + self.binary_paths = ['bin/ustreamer'] + self.cam = None + + async def execute(self, lock: asyncio.Lock): + if self.parameters['no_proxy'].value: + host = '0.0.0.0' + logger.log_info("Set to 'no_proxy' mode! Using 0.0.0.0!") + else: + host = '127.0.0.1' + port = self.parameters['port'].value + res = self.parameters['resolution'].value + fps = self.parameters['max_fps'].value + device = self.parameters['device'].value + self.cam = camera.camera_manager.get_cam_by_path(device) + + streamer_args = [ + '--host', host, + '--port', str(port), + '--resolution', str(res), + '--desired-fps', str(fps), + # webroot & allow crossdomain requests + '--allow-origin', '\*', + '--static', '"ustreamer-www"' + ] + + if self._is_device_legacy(): + streamer_args += [ + '--format', 'MJPEG', + '--device-timeout', '5', + '--buffers', '3' + ] + self._blockyfix() + else: + streamer_args += [ + '--device', device, + '--device-timeout', '2' + ] + if self.cam and self.cam.has_mjpg_hw_encoder(): + streamer_args += [ + '--format', 'MJPEG', + '--encoder', 'HW' + ] + + v4l2ctl = self.parameters['v4l2ctl'].value + if v4l2ctl: + self._set_v4l2ctrls(self.cam, v4l2ctl.split(',')) + + # custom flags + streamer_args += self.parameters['custom_flags'].value.split() + + cmd = self.binary_path + ' ' + ' '.join(streamer_args) + log_pre = f'{self.keyword} [cam {self.name}]: ' + + logger.log_debug(log_pre + f"Parameters: {' '.join(streamer_args)}") + process,_,_ = await utils.execute_command( + cmd, + info_log_pre=log_pre, + info_log_func=logger.log_debug, + error_log_pre=log_pre, + error_log_func=self._custom_log + ) + if lock.locked(): + lock.release() + + await asyncio.sleep(0.5) + for ctl in v4l2ctl.split(','): + if 'focus_absolute' in ctl: + focus_absolute = ctl.split('=')[1].strip() + self._brokenfocus(focus_absolute) + break + + return process + + def _custom_log(self, msg: str): + if msg.endswith('==='): + msg = msg[:-28] + else: + msg = re.sub(r'-- (.*?) \[.*?\] --', r'\1', msg) + logger.log_debug(msg) + + def _set_v4l2_ctrl(self, ctrl: str, prefix='') -> str: + try: + c = ctrl.split('=')[0].strip().lower() + v = int(ctrl.split('=')[1].strip()) + if not self.cam or not self.cam.set_control(c, v): + raise ValueError + except (ValueError, IndexError): + logger.log_quiet(f"Failed to set parameter: '{ctrl.strip()}'", prefix) + + def _set_v4l2ctrls(self, ctrls: list[str] = None) -> str: + section = f'[cam {self.name}]' + prefix = "V4L2 Control: " + if not ctrls: + logger.log_quiet(f"No parameters set for {section}. Skipped.", prefix) + return + logger.log_quiet(f"Device: {section}", prefix) + logger.log_quiet(f"Options: {', '.join(ctrls)}", prefix) + avail_ctrls = self.cam.get_controls_string() + for ctrl in ctrls: + c = ctrl.split('=')[0].strip().lower() + if c not in avail_ctrls: + logger.log_quiet( + f"Parameter '{ctrl.strip()}' not available for '{self.parameters['device'].value}'. Skipped.", + prefix + ) + continue + self._set_v4l2_ctrl(self.cam, ctrl, prefix) + # Repulls the string to print current values + logger.log_multiline(self.cam.get_controls_string(), logger.log_debug, 'DEBUG: v4l2ctl: ') + + def _brokenfocus(self, focus_absolute_conf: str) -> str: + cur_val = self.cam.get_current_control_value('focus_absolute') + if cur_val and cur_val != focus_absolute_conf: + logger.log_warning(f"Detected 'brokenfocus' device.") + logger.log_info(f"Try to set to configured Value.") + self.set_v4l2_ctrl(self.cam, f'focus_absolute={focus_absolute_conf}') + logger.log_debug(f"Value is now: {self.cam.get_current_control_value('focus_absolute')}") + + def _is_device_legacy(self) -> bool: + return isinstance(self.cam, camera.Legacy) + + +def load_component(name: str): + return Ustreamer(name) diff --git a/pylibs/logger.py b/pylibs/logger.py new file mode 100644 index 00000000..c09e8220 --- /dev/null +++ b/pylibs/logger.py @@ -0,0 +1,70 @@ +#!/usr/bin/python3 + +import logging +import logging.handlers + +import os +import sys + +DEV = 10 +DEBUG = 15 +QUIET = 35 + +indentation = 6*' ' + +def setup_logging(log_path, filemode='a', log_level=logging.INFO): + global logger + # Create log directory if it does not exist. + os.makedirs(os.path.dirname(log_path), exist_ok=True) + + # Change DEBUG to DEB and add custom logging level. + logging.addLevelName(DEV, 'DEV') + logging.addLevelName(DEBUG, 'DEBUG') + logging.addLevelName(QUIET, 'QUIET') + + logger = logging.getLogger('crowsnest') + logger.propagate = False + formatter = logging.Formatter('[%(asctime)s] %(message)s', datefmt='%d/%m/%y %H:%M:%S') + + # WatchedFileHandler for log file. This handler will reopen the file if it is moved or deleted. + # filehandler = logging.handlers.WatchedFileHandler(log_path, mode=filemode, encoding='utf-8') + filehandler = logging.handlers.RotatingFileHandler(log_path, mode=filemode, encoding='utf-8') + filehandler.setFormatter(formatter) + logger.addHandler(filehandler) + + # StreamHandler for stdout. + streamhandler = logging.StreamHandler(sys.stdout) + streamhandler.setFormatter(formatter) + logger.addHandler(streamhandler) + + # Set log level. + logger.setLevel(log_level) + +def set_log_level(level): + global logger + logger.setLevel(level) + +def log_quiet(msg, prefix=''): + global logger + logger.log(QUIET, prefix + msg) + +def log_info(msg, prefix='INFO: '): + global logger + logger.info(prefix + msg) + +def log_debug(msg, prefix='DEBUG: '): + global logger + logger.log(DEBUG, prefix + msg) + +def log_warning(msg, prefix='WARN: '): + global logger + logger.warning(prefix + msg) + +def log_error(msg, prefix='ERROR: '): + global logger + logger.error(prefix + msg) + +def log_multiline(msg, log_func, *args): + lines = msg.split('\n') + for line in lines: + log_func(line, *args) diff --git a/pylibs/logging_helper.py b/pylibs/logging_helper.py new file mode 100644 index 00000000..f18700a1 --- /dev/null +++ b/pylibs/logging_helper.py @@ -0,0 +1,138 @@ +#!/usr/bin/python3 + +import re +import os +import sys +import shutil + +from . import utils, logger, camera +from .components.streamer.streamer import Streamer, load_all_streamers + +def log_initial(): + logger.log_quiet('crowsnest - A webcam Service for multiple Cams and Stream Services.') + command = 'git describe --always --tags' + version = utils.execute_shell_command(command) + logger.log_quiet(f'Version: {version}') + logger.log_quiet('Prepare Startup ...') + +def log_host_info(): + logger.log_info("Host Information:") + log_pre = logger.indentation + + ### OS Infos + # OS Version + distribution = utils.grep('/etc/os-release', 'PRETTY_NAME') + distribution = distribution.strip().split('=')[1].strip('"') + logger.log_info(f'Distribution: {distribution}', log_pre) + + # Release Version of MainsailOS (if file present) + try: + with open('/etc/mainsailos-release', 'r') as file: + content = file.read() + logger.log_info(f'Release: {content.strip()}', log_pre) + except FileNotFoundError: + pass + + # Kernel Version + uname = os.uname() + logger.log_info(f'Kernel: {uname.sysname} {uname.release} {uname.machine}', log_pre) + + + ### Host Machine Infos + # Host model + model = utils.grep('/proc/cpuinfo', 'Model').split(':')[1].strip() + if model == '': + model == utils.grep('/proc/cpuinfo', 'model name').split(':')[1].strip() + if model == '': + model = 'Unknown' + logger.log_info(f'Model: {model}', log_pre) + + # CPU count + cpu_count = os.cpu_count() + logger.log_info(f"Available CPU Cores: {cpu_count}", log_pre) + + # Avail mem + memtotal = utils.grep('/proc/meminfo', 'MemTotal:').split(':')[1].strip() + logger.log_info(f'Available Memory: {memtotal}', log_pre) + + # Avail disk size + total, _, free = shutil.disk_usage("/") + total = utils.bytes_to_gigabytes(total) + free = utils.bytes_to_gigabytes(free) + logger.log_info(f'Diskspace (avail. / total): {free}G / {total}G', log_pre) + +def log_streamer(): + logger.log_info("Found Streamer:") + load_all_streamers() + log_pre = logger.indentation + for bin in Streamer.binaries: + if Streamer.binaries[bin] is None: + continue + logger.log_info(f'{bin}: {Streamer.binaries[bin]}', log_pre) + +def log_config(config_path): + logger.log_info("Print Configfile: '" + config_path + "'") + with open(config_path, 'r') as file: + config_txt = file.read() + # Remove comments + config_txt = re.sub(r'#.*$', "", config_txt, flags=re.MULTILINE) + # Remove multiple whitespaces next to each other at the end of a line + config_txt = re.sub(r'\s*$', "", config_txt, flags=re.MULTILINE) + # Add newlines before sections + config_txt = re.sub(r'(\[.*\])$', "\n\\1", config_txt, flags=re.MULTILINE) + # Remove leading and trailing whitespaces + config_txt = config_txt.strip() + # Split the config file into lines + logger.log_multiline(config_txt, logger.log_info, logger.indentation) + +def log_cams(): + logger.log_info("Detect available Devices") + libcamera = camera.camera_manager.init_camera_type(camera.Libcamera) + uvc = camera.camera_manager.init_camera_type(camera.UVC) + legacy = camera.camera_manager.init_camera_type(camera.Legacy) + total = len(libcamera) + len(legacy) + len(uvc) + + if total == 0: + logger.log_error("No usable Devices Found. Stopping ") + sys.exit() + + logger.log_info(f"Found {total} total available Device(s)") + if libcamera: + logger.log_info(f"Found {len(libcamera)} available 'libcamera' device(s)") + for cam in libcamera: + log_libcam(cam) + if legacy: + for cam in legacy: + log_legacy_cam(cam) + if uvc: + logger.log_info(f"Found {len(uvc)} available v4l2 (UVC) camera(s)") + for cam in uvc: + log_uvc_cam(cam) + +def log_libcam(cam: camera.Libcamera) -> None: + logger.log_info(f"Detected 'libcamera' device -> {cam.path}") + logger.log_info(f"Advertised Formats:", '') + log_camera_formats(cam) + logger.log_info(f"Supported Controls:", '') + log_camera_ctrls(cam) + +def log_uvc_cam(cam: camera.UVC) -> None: + logger.log_info(f"{cam.path_by_id} -> {cam.path}", '') + logger.log_info(f"Supported Formats:", '') + log_camera_formats(cam) + logger.log_info(f"Supported Controls:", '') + log_camera_ctrls(cam) + +def log_legacy_cam(camera_path: str) -> None: + cam: camera.UVC = camera.camera_manager.get_cam_by_path(camera_path) + logger.log_info(f"Detected 'Raspicam' Device -> {camera_path}") + logger.log_info(f"Supported Formats:", '') + log_camera_formats(cam) + logger.log_info(f"Supported Controls:", '') + log_camera_ctrls(cam) + +def log_camera_formats(cam: camera.Camera) -> None: + logger.log_multiline(cam.get_formats_string(), logger.log_info, logger.indentation) + +def log_camera_ctrls(cam: camera.Camera) -> None: + logger.log_multiline(cam.get_controls_string(), logger.log_info, logger.indentation) diff --git a/pylibs/parameter.py b/pylibs/parameter.py new file mode 100644 index 00000000..f06b5d10 --- /dev/null +++ b/pylibs/parameter.py @@ -0,0 +1,28 @@ +#!/usr/bin/python3 + +from . import logger + +class Parameter: + def __init__(self, type=str, default=None) -> None: + self.type = type + self.set_value(default) + + def set_value(self, value): + try: + if value is None: + self.value = None + elif self.type == bool: + if value.lower() == 'true': + self.value = True + elif value.lower() == 'false': + self.value = False + else: + raise ValueError() + else: + self.value = self.type(value) + except ValueError as e: + message = f"'{value}' is not of type '{self.type.__name__}'!" + if len(e.args) > 1 and e.args[0] == "Custom Error": + message = e.args[1] + message += " Parameter ignored!" + logger.log_error(message) diff --git a/pylibs/utils.py b/pylibs/utils.py new file mode 100644 index 00000000..73f1a66f --- /dev/null +++ b/pylibs/utils.py @@ -0,0 +1,106 @@ +#!/usr/bin/python3 + +import importlib +import asyncio +import subprocess +import shutil +import os + +from . import logger, v4l2 + +# Dynamically import component +# Requires module to have a load_component() function, +# as well as the same name as the section keyword +def load_component(component: str, + name: str, + path='pylibs.components'): + module_class = None + try: + component = importlib.import_module(f'{path}.{component}') + module_class = getattr(component, 'load_component')(name) + except (ModuleNotFoundError, AttributeError) as e: + logger.log_error(f"Failed to load module '{component}' from '{path}'") + return module_class + +async def log_subprocess_output(stream, log_func, line_prefix = ''): + line = await stream.readline() + while line: + l = line_prefix + l += line.decode('utf-8').strip() + log_func(l) + line = await stream.readline() + +async def execute_command( + command: str, + info_log_func = logger.log_debug, + error_log_func = logger.log_error, + info_log_pre = '', + error_log_pre = ''): + + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout_task = asyncio.create_task( + log_subprocess_output( + process.stdout, + info_log_func, + info_log_pre + ) + ) + stderr_task = asyncio.create_task( + log_subprocess_output( + process.stderr, + error_log_func, + error_log_pre + ) + ) + + return process, stdout_task, stderr_task + +def execute_shell_command(command: str, strip: bool = True) -> str: + try: + output = subprocess.check_output(command, shell=True).decode('utf-8') + if strip: + output = output.strip() + return output + except subprocess.CalledProcessError as e: + return '' + +def bytes_to_gigabytes(value: int) -> int: + return round(value / 1024**3) + +def find_file(name: str, path: str) -> str: + for dpath, _, fnames in os.walk(path): + for fname in fnames: + if fname == name: + return os.path.join(dpath, fname) + return None + +def get_executable(names: list[str], paths: list[str]) -> str: + if names is None or paths is None: + return None + for name in names: + for path in paths: + found = find_file(name, path) + if found: + return found + # Only search for installed packages, if there are no manually compiled binaries + for name in names: + exec = shutil.which(name) + if exec: + return exec + return None + +def grep(path: str, search: str) -> str: + try: + with open(path, 'r') as file: + lines = file.readlines() + for line in lines: + if search in line: + return line + except FileNotFoundError: + logger.log_error(f"File '{path}' not found!") + return '' diff --git a/pylibs/v4l2/__init__.py b/pylibs/v4l2/__init__.py new file mode 100644 index 00000000..73c1041d --- /dev/null +++ b/pylibs/v4l2/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python3 + +from . import constants, ctl, ioctl_macros, raw, utils diff --git a/pylibs/v4l2/constants.py b/pylibs/v4l2/constants.py new file mode 100644 index 00000000..38ae2945 --- /dev/null +++ b/pylibs/v4l2/constants.py @@ -0,0 +1,68 @@ +#!/usr/bin/python3 + +V4L2_CTRL_MAX_DIMS = 4 + +V4L2_CTRL_TYPE_INTEGER = 1 +V4L2_CTRL_TYPE_BOOLEAN = 2 +V4L2_CTRL_TYPE_MENU = 3 +V4L2_CTRL_TYPE_BUTTON = 4 +V4L2_CTRL_TYPE_INTEGER64 = 5 +V4L2_CTRL_TYPE_CTRL_CLASS = 6 +V4L2_CTRL_TYPE_STRING = 7 +V4L2_CTRL_TYPE_BITMASK = 8 +V4L2_CTRL_TYPE_INTEGER_MENU = 9 + +V4L2_BUF_TYPE_VIDEO_CAPTURE = 1 +V4L2_BUF_TYPE_VIDEO_OUTPUT = 2 +V4L2_BUF_TYPE_VIDEO_OVERLAY = 3 +V4L2_BUF_TYPE_VBI_CAPTURE = 4 +V4L2_BUF_TYPE_VBI_OUTPUT = 5 +V4L2_BUF_TYPE_SLICED_VBI_CAPTURE = 6 +V4L2_BUF_TYPE_SLICED_VBI_OUTPUT = 7 +V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY = 8 +V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE = 9 +V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE = 10 +V4L2_BUF_TYPE_SDR_CAPTURE = 11 +V4L2_BUF_TYPE_SDR_OUTPUT = 12 +V4L2_BUF_TYPE_META_CAPTURE = 13 +V4L2_BUF_TYPE_META_OUTPUT = 14 + +V4L2_FMT_FLAG_COMPRESSED = 0x0001 +V4L2_FMT_FLAG_EMULATED = 0x0002 +V4L2_FMT_FLAG_CONTINUOUS_BYTESTREAM = 0x0004 +V4L2_FMT_FLAG_DYN_RESOLUTION = 0x0008 +V4L2_FMT_FLAG_ENC_CAP_FRAME_INTERVAL = 0x0010 +V4L2_FMT_FLAG_CSC_COLORSPACE = 0x0020 +V4L2_FMT_FLAG_CSC_XFER_FUNC = 0x0040 +V4L2_FMT_FLAG_CSC_YCBCR_ENC = 0x0080 +V4L2_FMT_FLAG_CSC_HSV_ENC = V4L2_FMT_FLAG_CSC_YCBCR_ENC +V4L2_FMT_FLAG_CSC_QUANTIZATION = 0x0100 + +V4L2_FRMSIZE_TYPE_DISCRETE = 1 +V4L2_FRMSIZE_TYPE_CONTINUOUS = 2 +V4L2_FRMSIZE_TYPE_STEPWISE = 3 + +V4L2_FRMIVAL_TYPE_DISCRETE = 1 +V4L2_FRMIVAL_TYPE_CONTINUOUS = 2 +V4L2_FRMIVAL_TYPE_STEPWISE = 3 + +# Control flags +V4L2_CTRL_FLAG_DISABLED = 0x0001 +V4L2_CTRL_FLAG_GRABBED = 0x0002 +V4L2_CTRL_FLAG_READ_ONLY = 0x0004 +V4L2_CTRL_FLAG_UPDATE = 0x0008 +V4L2_CTRL_FLAG_INACTIVE = 0x0010 +V4L2_CTRL_FLAG_SLIDER = 0x0020 +V4L2_CTRL_FLAG_WRITE_ONLY = 0x0040 +V4L2_CTRL_FLAG_VOLATILE = 0x0080 +V4L2_CTRL_FLAG_HAS_PAYLOAD = 0x0100 +V4L2_CTRL_FLAG_EXECUTE_ON_WRITE = 0x0200 +V4L2_CTRL_FLAG_MODIFY_LAYOUT = 0x0400 +V4L2_CTRL_FLAG_DYNAMIC_ARRAY = 0x0800 +# Query flags, to be ORed with the control ID +V4L2_CTRL_FLAG_NEXT_CTRL = 0x80000000 +V4L2_CTRL_FLAG_NEXT_COMPOUND = 0x40000000 +# User-class control IDs defined by V4L2 +V4L2_CID_MAX_CTRLS = 1024 +# IDs reserved for driver specific controls +V4L2_CID_PRIVATE_BASE = 0x08000000 diff --git a/pylibs/v4l2/ctl.py b/pylibs/v4l2/ctl.py new file mode 100644 index 00000000..ffa0052b --- /dev/null +++ b/pylibs/v4l2/ctl.py @@ -0,0 +1,219 @@ +#!/usr/bin/python3 + +import os +import copy + +from . import raw, constants, utils + +dev_ctls: dict[str, dict[str, dict[str, (raw.v4l2_ext_control, str)]]] = {} + +def parse_qc(fd: int, qc: raw.v4l2_query_ext_ctrl) -> dict: + """ + Parses the query control to an easy to use dictionary + """ + if qc.type == constants.V4L2_CTRL_TYPE_CTRL_CLASS: + return {} + controls = {} + controls['type'] = utils.v4l2_ctrl_type_to_string(qc.type) + if qc.type in (constants.V4L2_CTRL_TYPE_INTEGER, constants.V4L2_CTRL_TYPE_MENU): + controls['min'] = qc.minimum + controls['max'] = qc.maximum + if qc.type == constants.V4L2_CTRL_TYPE_INTEGER: + controls['step'] = qc.step + if qc.type in ( + constants.V4L2_CTRL_TYPE_INTEGER, + constants.V4L2_CTRL_TYPE_MENU, + constants.V4L2_CTRL_TYPE_INTEGER_MENU, + constants.V4L2_CTRL_TYPE_BOOLEAN + ): + controls['default'] = qc.default_value + if qc.flags: + controls['flags'] = utils.ctrlflags2str(qc.flags) + if qc.type in (constants.V4L2_CTRL_TYPE_MENU, constants.V4L2_CTRL_TYPE_INTEGER_MENU): + controls['menu'] = {} + for menu in utils.ioctl_iter( + fd, + raw.VIDIOC_QUERYMENU, + raw.v4l2_querymenu(id=qc.id), qc.minimum, qc.maximum + 1, qc.step, True + ): + if qc.type == constants.V4L2_CTRL_TYPE_MENU: + controls['menu'][menu.index] = menu.name.decode() + else: + controls['menu'][menu.index] = menu.value + return controls + +def parse_qc_of_path(device_path: str, qc: raw.v4l2_query_ext_ctrl) -> dict: + """ + Parses the query control to an easy to use dictionary + """ + try: + fd = os.open(device_path, os.O_RDWR) + controls = parse_qc(fd, qc) + os.close(fd) + return controls + except FileNotFoundError: + return {} + +def init_device(device_path: str) -> bool: + """ + Initialize a given device + """ + try: + fd = os.open(device_path, os.O_RDWR) + next_fl = constants.V4L2_CTRL_FLAG_NEXT_CTRL | constants.V4L2_CTRL_FLAG_NEXT_COMPOUND + qctrl = raw.v4l2_query_ext_ctrl(id=next_fl) + dev_ctls[device_path] = {} + for qc in utils.ioctl_iter(fd, raw.VIDIOC_QUERY_EXT_CTRL, qctrl): + if qc.type == constants.V4L2_CTRL_TYPE_CTRL_CLASS: + name = qc.name.decode() + else: + name = utils.name2var(qc.name.decode()) + dev_ctls[device_path][name] = { + 'qc': copy.deepcopy(qc), + 'values': parse_qc(fd, qc) + } + qc.id |= next_fl + os.close(fd) + return True + except FileNotFoundError: + return False + +def get_query_controls(device_path: str) -> dict[str, raw.v4l2_ext_control]: + """ + Initialize a given device + """ + try: + fd = os.open(device_path, os.O_RDWR) + next_fl = constants.V4L2_CTRL_FLAG_NEXT_CTRL | constants.V4L2_CTRL_FLAG_NEXT_COMPOUND + qctrl = raw.v4l2_query_ext_ctrl(id=next_fl) + query_controls: dict[str, raw.v4l2_query_ext_ctrl] = {} + utils.ioctl_safe(fd, raw.VIDIOC_G_EXT_CTRLS, qctrl) + for qc in utils.ioctl_iter(fd, raw.VIDIOC_QUERY_EXT_CTRL, qctrl): + if qc.type == constants.V4L2_CTRL_TYPE_CTRL_CLASS: + name = qc.name.decode() + else: + name = utils.name2var(qc.name.decode()) + query_controls[name] = copy.deepcopy(qc) + qc.id |= next_fl + os.close(fd) + return query_controls + except FileNotFoundError: + return {} + +def get_dev_ctl(device_path: str) -> dict: + if device_path not in dev_ctls: + if not init_device(device_path): + return None + return dev_ctls[device_path] + +def get_dev_ctl_parsed_dict(device_path: str) -> dict: + if device_path not in dev_ctls: + init_device(device_path) + return utils.ctl_to_parsed_dict(dev_ctls[device_path]) + +def get_dev_path_by_name(name: str) -> str: + """ + Get the device path by its name + """ + prefix = 'video' + for dev in os.listdir('/dev'): + if dev.startswith(prefix) and dev[len(prefix):].isdigit(): + path = f'/dev/{dev}' + if name in get_camera_capabilities(path).get('card'): + return path + return '' + +def get_camera_capabilities(device_path: str) -> dict: + """ + Get the capabilities of a given device + """ + try: + fd = os.open(device_path, os.O_RDWR) + cap = raw.v4l2_capability() + utils.ioctl_safe(fd, raw.VIDIOC_QUERYCAP, cap) + cap_dict = { + 'driver': cap.driver.decode(), + 'card': cap.card.decode(), + 'bus': cap.bus_info.decode(), + 'version': cap.version, + 'capabilities': cap.capabilities + } + os.close(fd) + return cap_dict + except FileNotFoundError: + return {} + +def get_control_cur_value(device_path: str, control: str) -> int: + """ + Get the current value of a control of a given device + """ + qc: raw.v4l2_query_ext_ctrl = dev_ctls[device_path][utils.name2var(control)]['qc'] + return get_control_cur_value_with_qc(device_path, qc, control) + +def get_control_cur_value_with_qc(device_path: str, qc: raw.v4l2_query_ext_ctrl) -> int: + """ + Get the current value of a control of a given device + """ + try: + fd = os.open(device_path, os.O_RDWR) + ctrl = raw.v4l2_control() + ctrl.id = qc.id + utils.ioctl_safe(fd, raw.VIDIOC_G_CTRL, ctrl) + os.close(fd) + return ctrl.value + except FileNotFoundError: + return None + +def set_control(device_path: str, control: str, value: int) -> bool: + """ + Set the value of a control of a given device + """ + qc: raw.v4l2_query_ext_ctrl = dev_ctls[device_path][control]['qc'] + return set_control_with_qc(device_path, qc, value) + +def set_control_with_qc(device_path: str, qc: raw.v4l2_query_ext_ctrl, value: int) -> bool: + success = False + try: + fd = os.open(device_path, os.O_RDWR) + ctrl = raw.v4l2_control() + ctrl.id = qc.id + ctrl.value = value + if utils.ioctl_safe(fd, raw.VIDIOC_S_CTRL, ctrl) != -1: + success = True + os.close(fd) + return success + except FileNotFoundError: + pass + return success + +def get_formats(device_path: str) -> dict: + """ + Get the available formats of a given device + """ + try: + fd = os.open(device_path, os.O_RDWR) + fmt = raw.v4l2_fmtdesc() + frmsize = raw.v4l2_frmsizeenum() + frmival = raw.v4l2_frmivalenum() + fmt.index = 0 + fmt.type = constants.V4L2_BUF_TYPE_VIDEO_CAPTURE + formats = {} + for fmt in utils.ioctl_iter(fd, raw.VIDIOC_ENUM_FMT, fmt): + str = f"[{fmt.index}]: '{utils.fcc2s(fmt.pixelformat)}' ({fmt.description.decode()}" + if fmt.flags: + str += f", {utils.fmtflags2str(fmt.flags)}" + str += ')' + formats[str] = {} + frmsize.pixel_format = fmt.pixelformat + for size in utils.ioctl_iter(fd, raw.VIDIOC_ENUM_FRAMESIZES, frmsize): + size_str = utils.frmsize_to_str(size) + formats[str][size_str] = [] + frmival.pixel_format = fmt.pixelformat + frmival.width = frmsize.discrete.width + frmival.height = frmsize.discrete.height + for interval in utils.ioctl_iter(fd, raw.VIDIOC_ENUM_FRAMEINTERVALS, frmival): + formats[str][size_str].append(utils.frmival_to_str(interval)) + os.close(fd) + return formats + except FileNotFoundError: + return {} diff --git a/pylibs/v4l2/ioctl_macros.py b/pylibs/v4l2/ioctl_macros.py new file mode 100644 index 00000000..08ccbb75 --- /dev/null +++ b/pylibs/v4l2/ioctl_macros.py @@ -0,0 +1,77 @@ +# Methods to create IOCTL requests +# +# Copyright (C) 2023 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license + +from __future__ import annotations +import ctypes +from typing import Union, Type, TYPE_CHECKING + +""" +This module contains of Python port of the macros avaialble in +"/include/uapi/asm-generic/ioctl.h" from the linux kernel. +""" + +if TYPE_CHECKING: + IOCParamSize = Union[int, str, Type[ctypes._CData]] + +_IOC_NRBITS = 8 +_IOC_TYPEBITS = 8 + +# NOTE: The following could be platform specific. +_IOC_SIZEBITS = 14 +_IOC_DIRBITS = 2 + +_IOC_NRMASK = (1 << _IOC_NRBITS) - 1 +_IOC_TYPEMASK = (1 << _IOC_TYPEBITS) - 1 +_IOC_SIZEMASK = (1 << _IOC_SIZEBITS) - 1 +_IOC_DIRMASK = (1 << _IOC_DIRBITS) - 1 + +_IOC_NRSHIFT = 0 +_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS +_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS +_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS + +# The constants below may also be platform specific +IOC_NONE = 0 +IOC_WRITE = 1 +IOC_READ = 2 + +def _check_value(val: int, name: str, maximum: int): + if val > maximum: + raise ValueError(f"Value '{val}' for '{name}' exceeds max of {maximum}") + +def _IOC_TYPECHECK(param_size: IOCParamSize) -> int: + if isinstance(param_size, int): + return param_size + elif isinstance(param_size, bytearray): + return len(param_size) + elif isinstance(param_size, str): + ctcls = getattr(ctypes, param_size) + return ctypes.sizeof(ctcls) + return ctypes.sizeof(param_size) + +def IOC(direction: int, cmd_type: int, cmd_number: int, param_size: int) -> int: + _check_value(direction, "direction", _IOC_DIRMASK) + _check_value(cmd_type, "cmd_type", _IOC_TYPEMASK) + _check_value(cmd_number, "cmd_number", _IOC_NRMASK) + _check_value(param_size, "ioc_size", _IOC_SIZEMASK) + return ( + (direction << _IOC_DIRSHIFT) | + (param_size << _IOC_SIZESHIFT) | + (cmd_type << _IOC_TYPESHIFT) | + (cmd_number << _IOC_NRSHIFT) + ) + +def IO(cmd_type: int, cmd_number: int) -> int: + return IOC(IOC_NONE, cmd_type, cmd_number, 0) + +def IOR(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: + return IOC(IOC_READ, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) + +def IOW(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: + return IOC(IOC_WRITE, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) + +def IOWR(cmd_type: int, cmd_number: int, param_size: IOCParamSize) -> int: + return IOC(IOC_READ | IOC_WRITE, cmd_type, cmd_number, _IOC_TYPECHECK(param_size)) diff --git a/pylibs/v4l2/raw.py b/pylibs/v4l2/raw.py new file mode 100644 index 00000000..de3af164 --- /dev/null +++ b/pylibs/v4l2/raw.py @@ -0,0 +1,193 @@ +#!/usr/bin/python3 + +import ctypes + +from . import ioctl_macros + +from . import constants + +class v4l2_capability(ctypes.Structure): + _fields_ = [ + ("driver",ctypes.c_char * 16), + ("card", ctypes.c_char * 32), + ("bus_info", ctypes.c_char * 32), + ("version", ctypes.c_uint32), + ("capabilities", ctypes.c_uint32), + ("device_caps", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 3) + ] + +class v4l2_fmtdesc(ctypes.Structure): + _fields_ = [ + ("index", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("description", ctypes.c_char * 32), + ("pixelformat", ctypes.c_uint32), + ("mbus_code", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 3) + ] + +class v4l2_control(ctypes.Structure): + _fields_ = [ + ("id", ctypes.c_uint32), + ("value", ctypes.c_int32) + ] + +class v4l2_queryctrl(ctypes.Structure): + _fields_ = [ + ("id", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("name", ctypes.c_char * 8), + ("minimum", ctypes.c_int32), + ("maximum", ctypes.c_int32), + ("step", ctypes.c_int32), + ("default_value", ctypes.c_int32), + ("flags", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 2) + ] + +class v4l2_querymenu(ctypes.Structure): + class UnionNameValue(ctypes.Union): + _fields_ = [ + ("name", ctypes.c_char * 32), + ("value", ctypes.c_int64) + ] + _pack_ = True + _fields_ = [ + ("id", ctypes.c_uint32), + ("index", ctypes.c_uint32), + ("union", UnionNameValue), + ("reserved", ctypes.c_uint32) + ] + _anonymous_ = ("union",) + +class v4l2_ext_control(ctypes.Structure): + _pack_ = True + class ValueUnion(ctypes.Union): + _fields_ = [ + ("value", ctypes.c_int32), + ("value64", ctypes.c_int64), + ("string", ctypes.POINTER(ctypes.c_char)), + ("p_u8", ctypes.POINTER(ctypes.c_uint8)), + ("p_u16", ctypes.POINTER(ctypes.c_uint16)), + ("p_u32", ctypes.POINTER(ctypes.c_uint32)), + ("p_s32", ctypes.POINTER(ctypes.c_int32)), + ("p_s64", ctypes.POINTER(ctypes.c_int64)), + ("ptr", ctypes.POINTER(None)) + ] + + _fields_ = [ + ("id", ctypes.c_uint32), + ("size", ctypes.c_uint32), + ("reserved2", ctypes.c_uint32 * 1), + ("union", ValueUnion) + ] + _anonymous_ = ("union",) + +class v4l2_ext_controls(ctypes.Structure): + class UnionControls(ctypes.Union): + _fields_ = [ + ("ctrl_class", ctypes.c_uint32), + ("which", ctypes.c_uint32) + ] + + _fields_ = [ + ("union", UnionControls), + ("count", ctypes.c_uint32), + ("error_idx", ctypes.c_uint32), + ("request_fd", ctypes.c_int32), + ("reserved", ctypes.c_uint32 * 1), + ("controls", ctypes.POINTER(v4l2_ext_control) ) + ] + _anonymous_ = ("union",) + +class v4l2_frmsize_discrete(ctypes.Structure): + _fields_ = [ + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32) + ] + +class v4l2_frmsize_stepwise(ctypes.Structure): + _fields_ = [ + ("min_width", ctypes.c_uint32), + ("max_width", ctypes.c_uint32), + ("step_width", ctypes.c_uint32), + ("min_height", ctypes.c_uint32), + ("max_height", ctypes.c_uint32), + ("step_height", ctypes.c_uint32) + ] + +class v4l2_frmsizeenum(ctypes.Structure): + class FrmSize(ctypes.Union): + _fields_ = [ + ("discrete", v4l2_frmsize_discrete), + ("stepwise", v4l2_frmsize_stepwise) + ] + _fields_ = [ + ("index", ctypes.c_uint32), + ("pixel_format", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("union", FrmSize), + ("reserved", ctypes.c_uint32 * 2) + ] + _anonymous_ = ("union",) + +class v4l2_fract(ctypes.Structure): + _fields_ = [ + ("numerator", ctypes.c_uint32), + ("denominator", ctypes.c_uint32) + ] +class v4l2_frmival_stepwise(ctypes.Structure): + _fields_ = [ + ("min", v4l2_fract), + ("max", v4l2_fract), + ("step", v4l2_fract) + ] + +class v4l2_frmivalenum(ctypes.Structure): + class FrmIval(ctypes.Union): + _fields_ = [ + ("discrete", v4l2_fract), + ("stepwise", v4l2_frmival_stepwise) + ] + _fields_ = [ + ("index", ctypes.c_uint32), + ("pixel_format",ctypes.c_uint32), + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("union", FrmIval), + ("reserved", ctypes.c_uint32 * 2) + ] + _anonymous_ = ("union",) + +class v4l2_query_ext_ctrl(ctypes.Structure): + _fields_ = [ + ("id", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("name", ctypes.c_char * 32), + ("minimum", ctypes.c_int64), + ("maximum", ctypes.c_int64), + ("step", ctypes.c_uint64), + ("default_value", ctypes.c_int64), + ("flags", ctypes.c_uint32), + ("elem_size", ctypes.c_uint32), + ("elems", ctypes.c_uint32), + ("nr_of_dims", ctypes.c_uint32), + ("dim", ctypes.c_uint32 * constants.V4L2_CTRL_MAX_DIMS), + ("reserved", ctypes.c_uint32 * 32) + ] + + +VIDIOC_QUERYCAP = ioctl_macros.IOR(ord('V'), 0, v4l2_capability) +VIDIOC_ENUM_FMT = ioctl_macros.IOWR(ord('V'), 2, v4l2_fmtdesc) +VIDIOC_G_CTRL = ioctl_macros.IOWR(ord('V'), 27, v4l2_control) +VIDIOC_S_CTRL = ioctl_macros.IOWR(ord('V'), 28, v4l2_control) +VIDIOC_QUERYCTRL = ioctl_macros.IOWR(ord('V'), 36, v4l2_queryctrl) +VIDIOC_QUERYMENU = ioctl_macros.IOWR(ord('V'), 37, v4l2_querymenu) +VIDIOC_G_EXT_CTRLS = ioctl_macros.IOWR(ord('V'), 71, v4l2_ext_controls) +VIDIOC_S_EXT_CTRLS = ioctl_macros.IOWR(ord('V'), 72, v4l2_ext_controls) +VIDIOC_ENUM_FRAMESIZES = ioctl_macros.IOWR(ord('V'), 74, v4l2_frmsizeenum) +VIDIOC_ENUM_FRAMEINTERVALS = ioctl_macros.IOWR(ord('V'), 75, v4l2_frmivalenum) +VIDIOC_QUERY_EXT_CTRL = ioctl_macros.IOWR(ord('V'), 103, v4l2_query_ext_ctrl) diff --git a/pylibs/v4l2/utils.py b/pylibs/v4l2/utils.py new file mode 100644 index 00000000..9ed25f12 --- /dev/null +++ b/pylibs/v4l2/utils.py @@ -0,0 +1,165 @@ +#!/usr/bin/python3 + +import fcntl +import ctypes +import re +import errno +from typing import Generator + +from . import raw, constants + + +def ioctl_safe(fd: int, request: int, arg: ctypes.Structure) -> int: + try: + return fcntl.ioctl(fd, request, arg) + except OSError as e: + return -1 + +def ioctl_iter(fd: int, cmd: int, struct: ctypes.Structure, + start=0, stop=128, step=1, ignore_einval=False + )-> Generator[ctypes.Structure, None, None]: + for i in range(start, stop, step): + struct.index = i + try: + fcntl.ioctl(fd, cmd, struct) + yield struct + except OSError as e: + if e.errno == errno.EINVAL: + if ignore_einval: + continue + break + elif e.errno in (errno.ENOTTY, errno.ENODATA, errno.EIO): + break + else: + break + +def v4l2_ctrl_type_to_string(ctrl_type: int) -> str: + dict_ctrl_type = { + constants.V4L2_CTRL_TYPE_INTEGER: "int", + constants.V4L2_CTRL_TYPE_BOOLEAN: "bool", + constants.V4L2_CTRL_TYPE_MENU: "menu", + constants.V4L2_CTRL_TYPE_BUTTON: "button", + constants.V4L2_CTRL_TYPE_INTEGER64: "int64", + constants.V4L2_CTRL_TYPE_CTRL_CLASS: "ctrl_class", + constants.V4L2_CTRL_TYPE_STRING: "str", + constants.V4L2_CTRL_TYPE_BITMASK: "bitmask", + constants.V4L2_CTRL_TYPE_INTEGER_MENU: "intmenu" + } + return dict_ctrl_type.get(ctrl_type, "unknown") + +def name2var(name: str) -> str: + return re.sub('[^0-9a-zA-Z]+', '_', name).lower() + +def ctrlflags2str(flags: int) -> str: + dict_flags = { + constants.V4L2_CTRL_FLAG_GRABBED: "grabbed", + constants.V4L2_CTRL_FLAG_DISABLED: "disabled", + constants.V4L2_CTRL_FLAG_READ_ONLY: "read-only", + constants.V4L2_CTRL_FLAG_UPDATE: "update", + constants.V4L2_CTRL_FLAG_INACTIVE: "inactive", + constants.V4L2_CTRL_FLAG_SLIDER: "slider", + constants.V4L2_CTRL_FLAG_WRITE_ONLY: "write-only", + constants.V4L2_CTRL_FLAG_VOLATILE: "volatile", + constants.V4L2_CTRL_FLAG_HAS_PAYLOAD: "has-payload", + constants.V4L2_CTRL_FLAG_EXECUTE_ON_WRITE: "execute-on-write", + constants.V4L2_CTRL_FLAG_MODIFY_LAYOUT: "modify-layout", + constants.V4L2_CTRL_FLAG_DYNAMIC_ARRAY: "dynamic-array", + 0: None + } + return dict_flags.get(flags) + +def fmtflags2str(flags: int) -> str: + dict_flags = { + constants.V4L2_FMT_FLAG_COMPRESSED: "compressed", + constants.V4L2_FMT_FLAG_EMULATED: "emulated", + constants.V4L2_FMT_FLAG_CONTINUOUS_BYTESTREAM: "continuous-bytestream", + constants.V4L2_FMT_FLAG_DYN_RESOLUTION: "dyn-resolution", + constants.V4L2_FMT_FLAG_ENC_CAP_FRAME_INTERVAL: "enc-cap-frame-interval", + constants.V4L2_FMT_FLAG_CSC_COLORSPACE: "csc-colorspace", + constants.V4L2_FMT_FLAG_CSC_YCBCR_ENC: "csc-ycbcr-enc", + constants.V4L2_FMT_FLAG_CSC_QUANTIZATION: "csc-quantization", + constants.V4L2_FMT_FLAG_CSC_XFER_FUNC: "csc-xfer-func" + } + return dict_flags.get(flags) + +def fcc2s(val: int) -> str: + s = '' + s += chr(val & 0x7f) + s += chr((val >> 8) & 0x7f) + s += chr((val >> 16) & 0x7f) + s += chr((val >> 24) & 0x7f) + return s + +def frmtype2s(type) -> str: + types = [ + "Unknown", + "Discrete", + "Continuous", + "Stepwise" + ] + if type >= len(types): + return "Unknown" + return types[type] + +def fract2sec(fract: raw.v4l2_fract) -> str: + return "%.3f" % round(fract.numerator / fract.denominator, 3) + +def fract2fps(fract: raw.v4l2_fract) -> str: + return "%.3f" % round(fract.denominator / fract.numerator, 3) + +def frmsize_to_str(frmsize: raw.v4l2_frmsizeenum) -> str: + string = f"Size: {frmtype2s(frmsize.type)} " + if frmsize.type == constants.V4L2_FRMSIZE_TYPE_DISCRETE: + string += "%dx%d" % (frmsize.discrete.width, frmsize.discrete.height) + elif frmsize.type == constants.V4L2_FRMSIZE_TYPE_CONTINUOUS: + string += "%dx%d - %dx%d" % ( + frmsize.stepwise.min_width, + frmsize.stepwise.min_height, + frmsize.stepwise.max_width, + frmsize.stepwise.max_height + ) + elif frmsize.type == constants.V4L2_FRMSIZE_TYPE_STEPWISE: + string += "%ss - %ss with step %ss (%s-%s fps)" % ( + frmsize.stepwise.min_width, + frmsize.stepwise.min_height, + frmsize.stepwise.max_width, + frmsize.stepwise.max_height, + frmsize.stepwise.step_width, + frmsize.stepwise.step_height + ) + return string + +def frmival_to_str(frmival: raw.v4l2_frmivalenum) -> str: + string = f"Interval: {frmtype2s(frmival.type)} " + if frmival.type == constants.V4L2_FRMIVAL_TYPE_DISCRETE: + string += "%ss (%s fps)" % ( + fract2sec(frmival.discrete), + fract2fps(frmival.discrete) + ) + elif frmival.type == constants.V4L2_FRMIVAL_TYPE_CONTINUOUS: + string += "%ss - %ss (%s-%s fps)" % ( + fract2sec(frmival.stepwise.min), + fract2sec(frmival.stepwise.max), + fract2fps(frmival.stepwise.max), + fract2fps(frmival.stepwise.min) + ) + elif frmival.type == constants.V4L2_FRMIVAL_TYPE_STEPWISE: + string += "%ss - %ss with step %ss (%s-%s fps)" % ( + fract2sec(frmival.stepwise.min), + fract2sec(frmival.stepwise.max), + fract2sec(frmival.stepwise.step), + fract2fps(frmival.stepwise.max), + fract2fps(frmival.stepwise.min) + ) + return string + +def ctl_to_parsed_dict(dev_ctl: raw.v4l2_ext_control) -> dict: + values = {} + cur_sec = '' + for control, cur_ctl in dev_ctl.items(): + if not cur_ctl['values']: + cur_sec = control + values[cur_sec] = {} + continue + values[cur_sec][control] = cur_ctl['values'] + return values diff --git a/pylibs/watchdog.py b/pylibs/watchdog.py new file mode 100644 index 00000000..94dd063a --- /dev/null +++ b/pylibs/watchdog.py @@ -0,0 +1,29 @@ +#!/usr/bin/python3 + +import os +import asyncio +from . import logger + +configured_devices: list[str] = [] +lost_devices: list[str] = [] +running = True + +def crowsnest_watchdog(): + global configured_devices, lost_devices + prefix = "Watchdog: " + + for device in configured_devices: + if device.startswith('/base'): + continue + if device not in lost_devices and not os.path.exists(device): + lost_devices.append(device) + logger.log_quiet(f"Lost Devicve: '{device}'", prefix) + elif device in lost_devices and os.path.exists(device): + lost_devices.remove(device) + logger.log_quiet(f"Device '{device}' returned.", prefix) + +async def run_watchdog(): + global running + while running: + crowsnest_watchdog() + await asyncio.sleep(120) diff --git a/resources/crowsnest.conf b/resources/crowsnest.conf index ecb841d8..9414e44d 100644 --- a/resources/crowsnest.conf +++ b/resources/crowsnest.conf @@ -44,4 +44,4 @@ device: /dev/video0 # See Log for available ... resolution: 640x480 # widthxheight format max_fps: 15 # If Hardware Supports this it will be forced, otherwise ignored/coerced. #custom_flags: # You can run the Stream Services with custom flags. -#v4l2ctl: # Add v4l2-ctl parameters to setup your camera, see Log what your cam is capable of. +#v4l2ctl: # Add v4l2-ctl parameters to setup your camera, see Log what your cam is capable of. \ No newline at end of file