diff --git a/node/__main__.py b/node/app_api.py similarity index 65% rename from node/__main__.py rename to node/app_api.py index 22cbc85..ee14ecd 100644 --- a/node/__main__.py +++ b/node/app_api.py @@ -2,10 +2,7 @@ import logging import os -import signal -import threading from contextlib import asynccontextmanager -from types import FrameType from fastapi import FastAPI from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler @@ -29,31 +26,13 @@ def _create_basic_folders(): @asynccontextmanager async def lifespan(_: FastAPI): - # workaround to free resources on shutdown and prevent stalling - # https://github.com/encode/uvicorn/issues/1579#issuecomment-1419635974 - - # start app - default_sigint_handler = signal.getsignal(signal.SIGINT) - - def terminate_now(signum: int, frame: FrameType = None): - logger.info("shutting down app via signal handler") - container.stop() - default_sigint_handler(signum, frame) - - if threading.current_thread() is not threading.main_thread(): - # https://github.com/encode/uvicorn/pull/871 - # Signals can only be listened to from the main thread. - # usually only during testing, but no need in testing for this. - logger.info("lifecycle hook not installing signal, because current_thread not main_thread") - else: - logger.info("lifecycle hook installing signal to handle app shutdown") - signal.signal(signal.SIGINT, terminate_now) - # deliver app container.start() + logger.info("starting app") yield # Clean up - # container.stop() + logger.info("clean up") + container.stop() def _create_app() -> FastAPI: @@ -64,11 +43,10 @@ def _create_app() -> FastAPI: raise RuntimeError(f"cannot create data folders, error: {exc}") from exc _app = FastAPI( - title="Photobooth-App API", + title="Wigglecam Node API", description="API may change any time.", version=__version__, contact={"name": "mgineer85", "url": "https://github.com/photobooth-app/photobooth-app", "email": "me@mgineer85.de"}, - license_info={"name": "MIT", "url": "https://github.com/photobooth-app/photobooth-app/blob/main/LICENSE.md"}, docs_url="/api/doc", redoc_url=None, openapi_url="/api/openapi.json", diff --git a/node/app_minimal.py b/node/app_minimal.py new file mode 100644 index 0000000..658eab8 --- /dev/null +++ b/node/app_minimal.py @@ -0,0 +1,124 @@ +import logging +from datetime import datetime +from threading import Condition + +from gpiozero import Button as ZeroButton +from pydantic import BaseModel, Field, PrivateAttr +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .container import container +from .services.baseservice import BaseService + +logger = logging.getLogger(__name__) + + +class ConfigGpioPeripherial(BaseModel): + shutterbutton_in_pin_name: str = Field(default="GPIO4") + + +class AppMinimalConfig(BaseSettings): + """ + AppConfig class glueing all together + + In the case where a value is specified for the same Settings field in multiple ways, the selected value is determined as follows + (in descending order of priority): + + 1 Arguments passed to the Settings class initialiser. + 2 Environment variables, e.g. my_prefix_special_function as described above. + 3 Variables loaded from a dotenv (.env) file. + 4 Variables loaded from the secrets directory. + 5 The default field values for the Settings model. + """ + + _processed_at: datetime = PrivateAttr(default_factory=datetime.now) # private attributes + + # groups -> setting items + peripherial_gpio: ConfigGpioPeripherial = ConfigGpioPeripherial() + + model_config = SettingsConfigDict( + env_file_encoding="utf-8", + # first in following list is least important; last .env file overwrites the other. + env_file=[".env.dev", ".env.test", ".env.primary", ".env.node"], + env_nested_delimiter="__", + case_sensitive=True, + extra="ignore", + ) + + +class Button(ZeroButton): + def _fire_held(self): + # workaround for bug in gpiozero https://github.com/gpiozero/gpiozero/issues/697 + # https://github.com/gpiozero/gpiozero/issues/697#issuecomment-1480117579 + # Sometimes the kernel omits edges, so if the last + # deactivating edge is omitted held keeps firing. So + # check the current value and send a fake edge to + # EventsMixin to stop the held events. + if self.value: + super()._fire_held() + else: + self._fire_events(self.pin_factory.ticks(), False) + + +class GpioPeripherialService(BaseService): + def __init__(self, config: ConfigGpioPeripherial): + super().__init__() + + # init arguments + self._config: ConfigGpioPeripherial = config + + # define private props + self._shutterbutton_in: Button = None + self._shutterbutton_in_condition: Condition = None + + # init private props + self._shutterbutton_in_condition: Condition = Condition() + + def start(self): + super().start() + + # shutter button in + self._shutterbutton_in = Button(pin=self._config.shutterbutton_in_pin_name, bounce_time=0.04) + self._shutterbutton_in.when_pressed = self._on_shutterbutton + logger.info(f"external trigger button on {self._shutterbutton_in}") + + logger.debug(f"{self.__module__} started") + + def stop(self): + super().stop() + + if self._shutterbutton_in: + self._shutterbutton_in.close() + + def _on_shutterbutton(self): + logger.info("shutter button pressed") + with self._shutterbutton_in_condition: + self._shutterbutton_in_condition.notify_all() + + def wait_for_shutterbutton(self, timeout: float = None) -> None: + with self._shutterbutton_in_condition: + self._shutterbutton_in_condition.wait(timeout=timeout) + + +def main(): + container.start() + logger.info("starting app") + appminimalconfig = AppMinimalConfig() + gpioservice = GpioPeripherialService(appminimalconfig.peripherial_gpio) + gpioservice.start() + + try: + while True: + gpioservice.wait_for_shutterbutton() + container.synced_acquisition_service.execute_job() + + except KeyboardInterrupt: + print("got Ctrl+C, exiting") + + # Clean up + gpioservice.stop() + logger.info("clean up") + container.stop() + + +if __name__ == "__main__": + main() diff --git a/node/container.py b/node/container.py index 229f91f..6047dcf 100644 --- a/node/container.py +++ b/node/container.py @@ -1,9 +1,9 @@ import logging -from .config import appconfig from .services.baseservice import BaseService -from .services.gpio_primary_clockwork import GpioPrimaryClockworkService -from .services.sync_aquisition_service import SyncedAcquisitionService +from .services.config import appconfig +from .services.loggingservice import LoggingService +from .services.sync_acquisition_service import SyncedAcquisitionService logger = logging.getLogger(__name__) @@ -11,8 +11,8 @@ # and as globals module: class Container: # container - gpio_primary_service = GpioPrimaryClockworkService(config=appconfig.primary_gpio) - synced_acquisition_service = SyncedAcquisitionService() + logging_service = LoggingService(config=appconfig.logging) + synced_acquisition_service = SyncedAcquisitionService(config=appconfig.syncedacquisition) def _service_list(self) -> list[BaseService]: # list used to start/stop services. List sorted in the order of definition. diff --git a/node/routers/api/__init__.py b/node/routers/api/__init__.py index 5b012f8..f5aac17 100644 --- a/node/routers/api/__init__.py +++ b/node/routers/api/__init__.py @@ -2,13 +2,13 @@ from fastapi import APIRouter -from . import aquisition, system +from . import acquisition, system __all__ = [ - "aquisition", # refers to the 'aquisition.py' file + "acquisition", # refers to the 'acquisition.py' file "system", ] router = APIRouter(prefix="/api") -router.include_router(aquisition.router) +router.include_router(acquisition.router) router.include_router(system.router) diff --git a/node/routers/api/aquisition.py b/node/routers/api/acquisition.py similarity index 87% rename from node/routers/api/aquisition.py rename to node/routers/api/acquisition.py index 71fd8d4..3e9608c 100644 --- a/node/routers/api/aquisition.py +++ b/node/routers/api/acquisition.py @@ -7,8 +7,8 @@ logger = logging.getLogger(__name__) router = APIRouter( - prefix="/aquisition", - tags=["aquisition"], + prefix="/acquisition", + tags=["acquisition"], ) @@ -33,12 +33,12 @@ def video_stream(): @router.get("/setup") def setup_job(job_id, number_captures): - pass + container.synced_acquisition_service.setup_job() @router.get("/trigger") def trigger_job(job_id): - pass + container.synced_acquisition_service.execute_job() @router.get("/results") diff --git a/node/services/backends/cameras/picamera2backend.py b/node/services/backends/cameras/picamera2backend.py index 5d1a37e..5a542b8 100644 --- a/node/services/backends/cameras/picamera2backend.py +++ b/node/services/backends/cameras/picamera2backend.py @@ -10,7 +10,7 @@ from picamera2.encoders import MJPEGEncoder from picamera2.outputs import FileOutput -from ....config.models import ConfigPicamera2 +from ...config.models import ConfigBackendPicamera2 from .backend import BaseBackend, StreamingOutput logger = logging.getLogger(__name__) @@ -20,10 +20,10 @@ class Picamera2Backend(BaseBackend): - def __init__(self, config: ConfigPicamera2): + def __init__(self, config: ConfigBackendPicamera2): super().__init__() # init with arguments - self._config: ConfigPicamera2 = config + self._config: ConfigBackendPicamera2 = config # private props self._picamera2: Picamera2 = None @@ -38,7 +38,7 @@ def __init__(self, config: ConfigPicamera2): self._capture = Event() self._streaming_output: StreamingOutput = StreamingOutput() - print(f"global_camera_info {Picamera2.global_camera_info()}") + logger.info(f"global_camera_info {Picamera2.global_camera_info()}") def start(self, nominal_framerate: int = None): """To start the backend, configure picamera2""" @@ -74,7 +74,7 @@ def start(self, nominal_framerate: int = None): self._picamera2.options["quality"] = self._config.original_still_quality # capture_file image quality if self._config.enable_preview_display: - print("starting display preview") + logger.info("starting display preview") # Preview.DRM tested, but leads to many dropped frames. # Preview.QTGL currently not available on Pi3 according to own testing. # Preview.QT seems to work reasonably well, so use this for now hardcoded. @@ -84,8 +84,13 @@ def start(self, nominal_framerate: int = None): # self._qpicamera2 = QPicamera2(self._picamera2, width=800, height=480, keep_ar=True) else: pass + logger.info("preview disabled in config") # null Preview is automatically initialized and it needs at least one preview to drive the camera + # at this point we can receive the framedurationlimits valid for the selected configuration + # -> use to validate mode is possible, error if not possible, warn if very close + self._check_framerate() + # start camera self._picamera2.start() @@ -98,7 +103,7 @@ def start(self, nominal_framerate: int = None): logger.info(f"camera_controls: {self._picamera2.camera_controls}") logger.info(f"controls: {self._picamera2.controls}") logger.info(f"camera_properties: {self._picamera2.camera_properties}") - print(f"nominal framerate set to {self._nominal_framerate}") + logger.info(f"nominal framerate set to {self._nominal_framerate}") logger.debug(f"{self.__module__} started") def stop(self): @@ -117,11 +122,11 @@ def start_stream(self): # low power devices but this can cause issues with timing it seems and permanent non-synchronizity self._picamera2.start_recording(encoder, FileOutput(self._streaming_output)) - print("encoding stream started") + logger.info("encoding stream started") def stop_stream(self): self._picamera2.stop_recording() - print("encoding stream stopped") + logger.info("encoding stream stopped") def wait_for_lores_image(self): """for other threads to receive a lores JPEG image""" @@ -138,7 +143,23 @@ def sync_tick(self, timestamp_ns: int): try: self._queue_timestamp_monotonic_ns.put_nowait(timestamp_ns) except Full: - print("could not queue timestamp - camera not started or too low for nominal fps rate?") + logger.info("could not queue timestamp - camera not started, busy or nominal fps to close to cameras max mode fps?") + + def _check_framerate(self): + assert self._nominal_framerate is not None + + framedurationlimits = self._picamera2.camera_controls["FrameDurationLimits"][:2] # limits is in µs (min,max) + fpslimits = tuple([round(1.0 / (val * 1.0e-6), 1) for val in framedurationlimits]) # converted to frames per second fps + + logger.info(f"min frame duration {framedurationlimits[0]}, max frame duration {framedurationlimits[1]}") + logger.info(f"max fps {fpslimits[0]}, min fps {fpslimits[1]}") + + if self._nominal_framerate >= fpslimits[0] or self._nominal_framerate <= fpslimits[1]: + raise RuntimeError("nominal framerate is out of camera limits!") + + WARNING_THRESHOLD = 0.1 + if self._nominal_framerate > (fpslimits[0] * (1 - WARNING_THRESHOLD)) or self._nominal_framerate < fpslimits[1] * (1 + WARNING_THRESHOLD): + logger.warning("nominal framerate is close to cameras capabilities, this might have effect on sync performance!") def _init_autofocus(self): """ @@ -163,7 +184,7 @@ def clamp(n, min_value, max_value): return max(min_value, min(n, max_value)) def _camera_fun(self): - print("starting _camera_fun") + logger.debug("starting _camera_fun") timestamp_delta = 0 adjust_cycle_counter = 0 adjust_amount_us = 0 @@ -174,14 +195,17 @@ def _camera_fun(self): while self._is_running: if self._capture.is_set(): self._capture.clear() - print("####### capture #######") + logger.info("####### capture #######") folder = Path("./tmp/") filename = Path(f"img_{datetime.now().astimezone().strftime('%Y%m%d-%H%M%S-%f')}") filepath = folder / filename - print(f"{filepath=}") + logger.info(f"{filepath=}") - print(f"delta right before capture is {round(timestamp_delta/1e6,1)} ms") + if abs(timestamp_delta / 1.0e6) > 1.0: + logger.warning(f"camera captured out of sync, delta is {round(timestamp_delta/1e6,1)} ms for this frame") + else: + logger.info(f"delta right before capture is {round(timestamp_delta/1e6,1)} ms") tms = time.time() # take pick like following line leads to stalling in camera thread. the below method seems to have no effect on picam's cam thread @@ -191,7 +215,7 @@ def _camera_fun(self): request.save("main", filepath.with_suffix(".jpg")) request.release() - print(f"####### capture end, took {round((time.time() - tms), 2)}s #######") + logger.info(f"####### capture end, took {round((time.time() - tms), 2)}s #######") else: job = self._picamera2.capture_request(wait=False) # TODO: error checking, recovering from timeouts @@ -215,7 +239,7 @@ def _camera_fun(self): fixed_frame_duration = int(nominal_frame_duration_us - adjust_amount_clamped_us) ctrl.FrameDurationLimits = (fixed_frame_duration,) * 2 - print( + logger.debug( f"clock_in={round((capture_time_assigned_timestamp_ns or 0)/1e6,1)} ms, " f"sensor_timestamp={round(picam_metadata['SensorTimestamp']/1e6,1)} ms, " f"adjust_cycle_counter={adjust_cycle_counter}, " @@ -226,8 +250,4 @@ def _camera_fun(self): f"adjust_amount_clamped={round(adjust_amount_clamped_us/1e3,1)} ms " ) - print("_camera_fun left") - - -if __name__ == "__main__": - print("should not be started directly") + logger.info("_camera_fun left") diff --git a/node/services/backends/io/gpio_secondary_node.py b/node/services/backends/io/gpio_secondary_node.py deleted file mode 100644 index 5d9112d..0000000 --- a/node/services/backends/io/gpio_secondary_node.py +++ /dev/null @@ -1,120 +0,0 @@ -import logging -import time -from threading import Condition, Thread - -import gpiod - -from ....config.models import ConfigGpioSecondaryNode - -logger = logging.getLogger(__name__) - - -class GpioSecondaryNodeService: - def __init__(self, config: ConfigGpioSecondaryNode): - # init with arguments - self._config: ConfigGpioSecondaryNode = config - - # private props - self._gpiod_chip = None - self._gpiod_clock_in = None - self._gpiod_trigger_in = None - self._clock_in_condition: Condition = None - self._trigger_in_condition: Condition = None - self._clock_in_timestamp_ns = None - self._gpio_thread: Thread = None - - # init private props - self._gpiod_chip = gpiod.Chip(self._config.chip) - self._gpiod_clock_in = gpiod.find_line(self._config.clock_in_pin_name).offset() - self._gpiod_trigger_in = gpiod.find_line(self._config.trigger_in_pin_name).offset() - self._clock_in_condition: Condition = Condition() - self._trigger_in_condition: Condition = Condition() - - def start(self): - self._gpio_thread = Thread(name="_gpio_thread", target=self._gpio_fun, args=(), daemon=True) - self._gpio_thread.start() - - logger.debug(f"{self.__module__} started") - - def stop(self): - pass - - def clock_signal_valid(self) -> bool: - TIMEOUT_CLOCK_SIGNAL_INVALID = 0.5 * 1e9 # 0.5sec - if not self._clock_in_timestamp_ns: - return False - return (time.monotonic_ns() - self._clock_in_timestamp_ns) < TIMEOUT_CLOCK_SIGNAL_INVALID - - def derive_nominal_framerate_from_clock(self) -> int: - """calc the framerate derived by monitoring the clock signal for 11 ticks (means 10 intervals). - needs to set the nominal frame duration running freely while no adjustments are made to sync up - - Returns: - int: _description_ - """ - try: - first_timestamp_ns = self.wait_for_clock_signal(timeout=1) - for _ in range(10): - last_timestamp_ns = self.wait_for_clock_signal(timeout=1) - except TimeoutError as exc: - raise RuntimeError("no clock, cannot derive nominal framerate!") from exc - else: - duration_10_ticks_s = (last_timestamp_ns - first_timestamp_ns) * 1.0e-9 - duration_1_tick_s = duration_10_ticks_s / 10.0 # duration for 1_tick - fps = round(1.0 / duration_1_tick_s) # fps because fMaster=fCamera*1.0 currently - - return fps - - def _on_clock_in(self): - with self._clock_in_condition: - self._clock_in_timestamp_ns = time.monotonic_ns() - self._clock_in_condition.notify_all() - - def wait_for_clock_signal(self, timeout: float = 1.0) -> int: - with self._clock_in_condition: - if not self._clock_in_condition.wait(timeout=timeout): - raise TimeoutError("timeout receiving clock signal") - - return self._clock_in_timestamp_ns - - def _on_trigger_in(self): - with self._trigger_in_condition: - self._trigger_in_condition.notify_all() - - def wait_for_trigger_signal(self, timeout: float = None) -> int: - with self._trigger_in_condition: - self._trigger_in_condition.wait(timeout=timeout) - - def _event_callback(self, event): - # print(f"offset: {event.source.offset()} timestamp: [{event.sec}.{event.nsec}]") - if event.type == gpiod.LineEvent.RISING_EDGE: - if event.source.offset() == self._gpiod_clock_in: - self._on_clock_in() - elif event.source.offset() == self._gpiod_trigger_in: - self._on_trigger_in() - else: - raise ValueError("Invalid event source") - else: - raise TypeError("Invalid event type") - - def _gpio_fun(self): - print("starting _gpio_fun") - - # setup lines - lines_in = self._gpiod_chip.get_lines([self._gpiod_clock_in, self._gpiod_trigger_in]) - lines_in.request(consumer="clock_trigger_in", type=gpiod.LINE_REQ_EV_RISING_EDGE, flags=gpiod.LINE_REQ_FLAG_BIAS_PULL_DOWN) - - while True: - ev_lines = lines_in.event_wait(sec=1) - if ev_lines: - for line in ev_lines: - event = line.event_read() - self._event_callback(event) - else: - pass # nothing to do if no event came in - - print("_gpio_fun left") - - -if __name__ == "__main__": - print("should not be started directly") diff --git a/node/services/backends/io/gpiobackend.py b/node/services/backends/io/gpiobackend.py new file mode 100644 index 0000000..e7d8293 --- /dev/null +++ b/node/services/backends/io/gpiobackend.py @@ -0,0 +1,186 @@ +import logging +import os +import time +from pathlib import Path +from threading import Condition, Thread + +import gpiod +from gpiozero import DigitalOutputDevice + +from ...config.models import ConfigBackendGpio + +logger = logging.getLogger(__name__) + + +class GpioSecondaryNodeService: + def __init__(self, config: ConfigBackendGpio): + # init with arguments + self._config: ConfigBackendGpio = config + + # private props + self._gpiod_chip = None + self._gpiod_clock_in = None + self._gpiod_trigger_in = None + self._clock_rise_in_condition: Condition = None + self._clock_fall_in_condition: Condition = None + self._trigger_in_condition: Condition = None + self._clock_in_timestamp_ns = None + self._gpio_thread: Thread = None + self._trigger_out: DigitalOutputDevice = None + + # init private props + self._gpiod_chip = gpiod.Chip(self._config.chip) + self._gpiod_clock_in = gpiod.find_line(self._config.clock_in_pin_name).offset() + self._gpiod_trigger_in = gpiod.find_line(self._config.trigger_in_pin_name).offset() + self._clock_rise_in_condition: Condition = Condition() + self._clock_fall_in_condition: Condition = Condition() + self._trigger_in_condition: Condition = Condition() + + def start(self): + if self._config.enable_clock: + logger.info("loading primary clockwork service") + self.set_hardware_clock(enable=True) + logger.info("generating clock using hardware pwm overlay") + else: + logger.info("skipped loading primary clockwork service because disabled in config") + + self._trigger_out = DigitalOutputDevice(pin=self._config.trigger_out_pin_name, initial_value=False, active_high=True) + logger.info(f"forward trigger_out on {self._trigger_out}") + + self._gpio_thread = Thread(name="_gpio_thread", target=self._gpio_fun, args=(), daemon=True) + self._gpio_thread.start() + + logger.debug(f"{self.__module__} started") + + def stop(self): + if self._config.enable_clock: + self.set_hardware_clock(enable=False) + + if self._trigger_out: + self._trigger_out.close() + + def clock_signal_valid(self) -> bool: + TIMEOUT_CLOCK_SIGNAL_INVALID = 0.5 * 1e9 # 0.5sec + if not self._clock_in_timestamp_ns: + return False + return (time.monotonic_ns() - self._clock_in_timestamp_ns) < TIMEOUT_CLOCK_SIGNAL_INVALID + + def derive_nominal_framerate_from_clock(self) -> int: + """calc the framerate derived by monitoring the clock signal for 11 ticks (means 10 intervals). + needs to set the nominal frame duration running freely while no adjustments are made to sync up + + Returns: + int: _description_ + """ + try: + first_timestamp_ns = self.wait_for_clock_rise_signal(timeout=1) + for _ in range(10): + last_timestamp_ns = self.wait_for_clock_rise_signal(timeout=1) + except TimeoutError as exc: + raise RuntimeError("no clock, cannot derive nominal framerate!") from exc + else: + duration_10_ticks_s = (last_timestamp_ns - first_timestamp_ns) * 1.0e-9 + duration_1_tick_s = duration_10_ticks_s / 10.0 # duration for 1_tick + fps = round(1.0 / duration_1_tick_s) # fps because fMaster=fCamera*1.0 currently + + return fps + + def _on_clock_rise_in(self): + with self._clock_rise_in_condition: + self._clock_in_timestamp_ns = time.monotonic_ns() + self._clock_rise_in_condition.notify_all() + + def _on_clock_fall_in(self): + with self._clock_fall_in_condition: + self._clock_fall_in_condition.notify_all() + + def wait_for_clock_rise_signal(self, timeout: float = 1.0) -> int: + with self._clock_rise_in_condition: + if not self._clock_rise_in_condition.wait(timeout=timeout): + raise TimeoutError("timeout receiving clock signal") + + return self._clock_in_timestamp_ns + + def wait_for_clock_fall_signal(self, timeout: float = 1.0): + with self._clock_fall_in_condition: + if not self._clock_fall_in_condition.wait(timeout=timeout): + raise TimeoutError("timeout receiving clock signal") + + def set_hardware_clock(self, enable: bool = True): + """ + Export channel (0) + Set the period 1,000,000 ns (1kHz) + Set the duty_cycle 50% + Enable the PWM signal + + # https://raspberrypi.stackexchange.com/questions/143643/how-can-i-use-dtoverlay-pwm + # https://raspberrypi.stackexchange.com/questions/148769/troubleshooting-pwm-via-sysfs/148774#148774 + # 1/10FPS = 0.1 * 1e6 = 100.000.000ns period + # duty cycle = period / 2 + """ + PWM_CHANNEL = self._config.pwm_channel + PERIOD = int(1.0 / self._config.FPS_NOMINAL * 1e9) # 1e9=ns + # PERIOD = int(0.5 / self._config.FPS_NOMINAL * 1e9) # 1e9=ns # double frequency! + DUTY_CYCLE = PERIOD // 2 + PWM_SYSFS = Path(f"/sys/class/pwm/{self._config.pwmchip}") + + if not PWM_SYSFS.is_dir(): + raise RuntimeError("pwm overlay not enabled in config.txt") + + pwm_dir = PWM_SYSFS / f"pwm{PWM_CHANNEL}" + + if not os.access(pwm_dir, os.F_OK): + Path(PWM_SYSFS / "export").write_text(f"{PWM_CHANNEL}\n") + time.sleep(0.1) + + Path(pwm_dir / "period").write_text(f"{PERIOD}\n") + Path(pwm_dir / "duty_cycle").write_text(f"{DUTY_CYCLE}\n") + Path(pwm_dir / "enable").write_text(f"{1 if enable else 0}\n") + + logger.info(f"set hw clock sysfs chip {pwm_dir}, period={PERIOD}, duty_cycle={DUTY_CYCLE}, enable={1 if enable else 0}") + + def _on_trigger_in(self): + with self._trigger_in_condition: + self._trigger_in_condition.notify_all() + + def wait_for_trigger_signal(self, timeout: float = None): + with self._trigger_in_condition: + self._trigger_in_condition.wait(timeout=timeout) + + def _event_callback(self, event): + # print(f"offset: {event.source.offset()} timestamp: [{event.sec}.{event.nsec}]") + if event.type == gpiod.LineEvent.RISING_EDGE: + if event.source.offset() == self._gpiod_clock_in: + self._on_clock_rise_in() + elif event.source.offset() == self._gpiod_trigger_in: + self._on_trigger_in() + else: + raise ValueError("Invalid event source") + + elif event.type == gpiod.LineEvent.FALLING_EDGE: + if event.source.offset() == self._gpiod_clock_in: + self._on_clock_fall_in() + elif event.source.offset() == self._gpiod_trigger_in: + pass + else: + raise ValueError("Invalid event source") + else: + raise TypeError("Invalid event type") + + def _gpio_fun(self): + logger.debug("starting _gpio_fun") + + # setup lines + lines_in = self._gpiod_chip.get_lines([self._gpiod_clock_in, self._gpiod_trigger_in]) + lines_in.request(consumer="clock_trigger_in", type=gpiod.LINE_REQ_EV_BOTH_EDGES, flags=gpiod.LINE_REQ_FLAG_BIAS_PULL_DOWN) + + while True: + ev_lines = lines_in.event_wait(sec=1) + if ev_lines: + for line in ev_lines: + event = line.event_read() + self._event_callback(event) + else: + pass # nothing to do if no event came in + + logger.info("_gpio_fun left") diff --git a/node/config/__init__.py b/node/services/config/__init__.py similarity index 100% rename from node/config/__init__.py rename to node/services/config/__init__.py diff --git a/node/config/appconfig.py b/node/services/config/appconfig.py similarity index 74% rename from node/config/appconfig.py rename to node/services/config/appconfig.py index bb959b8..152a3e5 100644 --- a/node/config/appconfig.py +++ b/node/services/config/appconfig.py @@ -3,7 +3,7 @@ from pydantic import PrivateAttr from pydantic_settings import BaseSettings, SettingsConfigDict -from .models import ConfigGpioPrimaryClockwork, ConfigGpioSecondaryNode, ConfigPicamera2 +from .models import ConfigBackendGpio, ConfigBackendPicamera2, ConfigLogging, ConfigSyncedAcquisition class AppConfig(BaseSettings): @@ -23,9 +23,10 @@ class AppConfig(BaseSettings): _processed_at: datetime = PrivateAttr(default_factory=datetime.now) # private attributes # groups -> setting items - primary_gpio: ConfigGpioPrimaryClockwork = ConfigGpioPrimaryClockwork() - secondary_gpio: ConfigGpioSecondaryNode = ConfigGpioSecondaryNode() - picamera2: ConfigPicamera2 = ConfigPicamera2() + logging: ConfigLogging = ConfigLogging() + syncedacquisition: ConfigSyncedAcquisition = ConfigSyncedAcquisition() + backend_gpio: ConfigBackendGpio = ConfigBackendGpio() + backend_picamera2: ConfigBackendPicamera2 = ConfigBackendPicamera2() model_config = SettingsConfigDict( env_file_encoding="utf-8", diff --git a/node/config/models.py b/node/services/config/models.py similarity index 57% rename from node/config/models.py rename to node/services/config/models.py index ecbaac0..91bfa45 100644 --- a/node/config/models.py +++ b/node/services/config/models.py @@ -1,23 +1,27 @@ from pydantic import BaseModel, Field -class ConfigGpioPrimaryClockwork(BaseModel): - enable_primary_gpio: bool = Field(default=False) - # clock_out_pin_name: str = Field(default="GPIO18") # replaced by sysfs, need update - trigger_out_pin_name: str = Field(default="GPIO17") - ext_trigger_in_pin_name: str = Field(default="GPIO4") - FPS_NOMINAL: int = Field(default=9) # best to choose slightly below mode fps of camera - pwmchip: str = Field(default="pwmchip2") # pi5: 2, other 0 - pwm_channel: int = Field(default=2) # pi5: 2, other 0 +class ConfigLogging(BaseModel): + level: str = Field(default="DEBUG") + + +class ConfigSyncedAcquisition(BaseModel): + allow_standalone_job: bool = Field(default=True) -class ConfigGpioSecondaryNode(BaseModel): +class ConfigBackendGpio(BaseModel): chip: str = Field(default="/dev/gpiochip0") clock_in_pin_name: str = Field(default="GPIO14") trigger_in_pin_name: str = Field(default="GPIO15") + trigger_out_pin_name: str = Field(default="GPIO17") + + enable_clock: bool = Field(default=False) + FPS_NOMINAL: int = Field(default=9) # needs to be lower than cameras mode max fps to allow for control reserve + pwmchip: str = Field(default="pwmchip2") # pi5: pwmchip2, other pwmchip0 + pwm_channel: int = Field(default=2) # pi5: 2, other 0 -class ConfigPicamera2(BaseModel): +class ConfigBackendPicamera2(BaseModel): camera_num: int = Field(default=0) CAPTURE_CAM_RESOLUTION_WIDTH: int = Field(default=4608) CAPTURE_CAM_RESOLUTION_HEIGHT: int = Field(default=2592) diff --git a/node/services/gpio_primary_clockwork.py b/node/services/gpio_primary_clockwork.py deleted file mode 100644 index e1a2536..0000000 --- a/node/services/gpio_primary_clockwork.py +++ /dev/null @@ -1,111 +0,0 @@ -import logging -import os -import time -from pathlib import Path - -from gpiozero import Button as ZeroButton -from gpiozero import DigitalOutputDevice - -from ..config.models import ConfigGpioPrimaryClockwork -from .baseservice import BaseService - -logger = logging.getLogger(__name__) - - -class Button(ZeroButton): - def _fire_held(self): - # workaround for bug in gpiozero https://github.com/gpiozero/gpiozero/issues/697 - # https://github.com/gpiozero/gpiozero/issues/697#issuecomment-1480117579 - # Sometimes the kernel omits edges, so if the last - # deactivating edge is omitted held keeps firing. So - # check the current value and send a fake edge to - # EventsMixin to stop the held events. - if self.value: - super()._fire_held() - else: - self._fire_events(self.pin_factory.ticks(), False) - - -class GpioPrimaryClockworkService(BaseService): - def __init__(self, config: ConfigGpioPrimaryClockwork): - super().__init__() - - # init arguments - self._config: ConfigGpioPrimaryClockwork = config - - # define private props - self._trigger_out: DigitalOutputDevice = None - self._ext_trigger_in: Button = None - - # init private props - pass - - def start(self): - super().start() - - # hardware pwm is preferred - self.set_hardware_clock(enable=True) - print("generating clock using hardware pwm overlay") - - self._trigger_out = DigitalOutputDevice(pin=self._config.trigger_out_pin_name, initial_value=False, active_high=True) - print(f"forward trigger_out on {self._trigger_out}") - # TODO: 1)improve: maybe better to delay output until falling edge of clock comes in, - # send pulse and turn off again? avoids maybe race condition when trigger is setup right - # around the clock rise? - # TODO: 2) during above command the output glitches to high for short period and slave node detects a capture request :( - # maybe switch to gpiod for this gpio service also, just need a proper function to debounce the button then. - - self._ext_trigger_in = Button(pin=self._config.ext_trigger_in_pin_name, bounce_time=0.04) - self._ext_trigger_in.when_pressed = self._trigger_out.on - self._ext_trigger_in.when_released = self._trigger_out.off - print(f"external trigger button on {self._ext_trigger_in}") - - logger.debug(f"{self.__module__} started") - - def stop(self): - super().stop() - - self.set_hardware_clock(enable=False) - - if self._trigger_out: - self._trigger_out.close() - - if self._ext_trigger_in: - self._ext_trigger_in.close() - - def set_hardware_clock(self, enable: bool = True): - """ - Export channel (0) - Set the period 1,000,000 ns (1kHz) - Set the duty_cycle 50% - Enable the PWM signal - - # https://raspberrypi.stackexchange.com/questions/143643/how-can-i-use-dtoverlay-pwm - # https://raspberrypi.stackexchange.com/questions/148769/troubleshooting-pwm-via-sysfs/148774#148774 - # 1/10FPS = 0.1 * 1e6 = 100.000.000ns period - # duty cycle = period / 2 - """ - PWM_CHANNEL = self._config.pwm_channel - PERIOD = int(1.0 / self._config.FPS_NOMINAL * 1e9) # 1e9=ns - # PERIOD = int(0.5 / self._config.FPS_NOMINAL * 1e9) # 1e9=ns # double frequency! - DUTY_CYCLE = PERIOD // 2 - PWM_SYSFS = Path(f"/sys/class/pwm/{self._config.pwmchip}") - - if not PWM_SYSFS.is_dir(): - raise RuntimeError("pwm overlay not enabled in config.txt") - - pwm_dir = PWM_SYSFS / f"pwm{PWM_CHANNEL}" - - if not os.access(pwm_dir, os.F_OK): - Path(PWM_SYSFS / "export").write_text(f"{PWM_CHANNEL}\n") - time.sleep(0.1) - - Path(pwm_dir / "period").write_text(f"{PERIOD}\n") - Path(pwm_dir / "duty_cycle").write_text(f"{DUTY_CYCLE}\n") - Path(pwm_dir / "enable").write_text(f"{1 if enable else 0}\n") - - print(f"set hw clock sysfs chip {pwm_dir}, period={PERIOD}, duty_cycle={DUTY_CYCLE}, enable={1 if enable else 0}") - - -if __name__ == "__main__": - print("should not be started directly") diff --git a/node/services/loggingservice.py b/node/services/loggingservice.py new file mode 100644 index 0000000..e8017a2 --- /dev/null +++ b/node/services/loggingservice.py @@ -0,0 +1,142 @@ +""" +Control logging for the app +""" + +import logging +import os +import sys +import threading +import time +from datetime import datetime +from logging import FileHandler +from pathlib import Path + +from .baseservice import BaseService +from .config.models import ConfigLogging + +LOG_DIR = "log" + + +class LoggingService(BaseService): + """_summary_""" + + def __init__(self, config: ConfigLogging): + super().__init__() + + self._config: ConfigLogging = config + + # ensure dir exists + os.makedirs(LOG_DIR, exist_ok=True) + + ## formatter ## + fmt = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" + log_formatter = logging.Formatter(fmt=fmt) + + # filename per day to log to + logfile = Path(LOG_DIR, f"node_{datetime.now().astimezone().strftime('%Y%m%d')}.log") + + ## basic configuration + # latest basicConfig adds a streamHandler output to console if not automatically called + # earlier by some .warn .info or other + # force=False because otherwise the pytest console logger stream handler gets deleted + logging.basicConfig(level=logging.DEBUG, format=fmt, force=False, encoding="utf-8") + logging.debug("loggingservice __init__ basicConfig set") + logging.debug("loggingservice __init__ started") + + ## logger + # default logger (root = None or "") + # root logger also to be the template for all other loggers, + # that are created in the app at a later time during run + root_logger = logging.getLogger(name=None) + + # set level based on users config + root_logger.setLevel(self._config.level) + + ## handler + + self.file_handler = FileHandler(filename=logfile, mode="a", encoding="utf-8", delay=True) + self.file_handler.setFormatter(log_formatter) + + ## wire logger and handler ## + root_logger.addHandler(self.file_handler) + + ## mute other loggers + self.other_loggers() + + ## add the exepthooks + sys.excepthook = self._handle_sys_exception + threading.excepthook = self._handle_threading_exception + # no solution to handle exceptions in sep processes yet... + + logging.debug("loggingservice __init__ finished") + logging.debug(f"registered handlers: {logging.root.handlers}") + + self.remove_old_logs() + + def remove_old_logs(self): + DAYS = 7 + critical_time = DAYS * 86400 # 7 days + + now = time.time() + + for item in Path(LOG_DIR).glob("*.log"): + if item.is_file(): + if item.stat().st_mtime < (now - critical_time): + logging.info(f"deleting logfile older than {DAYS} days: {item}") + os.remove(item) + + def other_loggers(self): + """mute some logger by rasing their log level""" + + for name in [ + "picamera2.picamera2", + "sse_starlette.sse", + "PIL.PngImagePlugin", + "PIL.TiffImagePlugin", + "multipart", + "v4l2py", + "requests", + "urllib3", + ]: + # mute some other logger, by raising their debug level to INFO + lgr = logging.getLogger(name=name) + lgr.setLevel(logging.INFO) + lgr.propagate = True + + os.environ["OPENCV_LOG_LEVEL"] = "ERROR" + + def uvicorn(self): + """_summary_""" + + for name in [ + "uvicorn.error", + "uvicorn.access", + "uvicorn", + ]: + lgr = logging.getLogger(name=name) + lgr.setLevel(self._config.level) + lgr.propagate = False + lgr.handlers = [ + logging.root.handlers[0], # this is the streamhandler if not in pytest. + self.file_handler, + self.eventstream_handler, + ] + + @staticmethod + def _handle_sys_exception(exc_type, exc_value, exc_traceback): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + logging.getLogger(name="__main__").exception( + f"Uncaught exception: {exc_type} {exc_value}", + exc_info=(exc_type, exc_value, exc_traceback), + ) + + @staticmethod + def _handle_threading_exception(args: threading.ExceptHookArgs): + # report the failure + logging.getLogger(name="__main__").exception( + f"Uncaught exception in thread {args.thread}: {args.exc_type} {args.exc_value}", + exc_info=(args.exc_type, args.exc_value, args.exc_traceback), + ) diff --git a/node/services/sync_acquisition_service.py b/node/services/sync_acquisition_service.py new file mode 100644 index 0000000..29b938f --- /dev/null +++ b/node/services/sync_acquisition_service.py @@ -0,0 +1,200 @@ +import logging +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from threading import Event, Thread + +from .backends.cameras.picamera2backend import Picamera2Backend +from .backends.io.gpiobackend import GpioSecondaryNodeService +from .baseservice import BaseService +from .config import appconfig +from .config.models import ConfigSyncedAcquisition + +logger = logging.getLogger(__name__) + + +@dataclass +class CaptureJob: + id: str = field(default_factory=datetime.now) + number_captures: int = 1 + + +@dataclass +class CaptureJobResult: + id: str = None + filenames: list[Path] = field(default_factory=list) + + +class SyncedAcquisitionService(BaseService): + def __init__(self, config: ConfigSyncedAcquisition): + super().__init__() + + # init the arguments + self._config: ConfigSyncedAcquisition = config + + # define private props + # to sync, a camera backend and io backend is used. + self._camera_backend: Picamera2Backend = None + self._gpio_backend: GpioSecondaryNodeService = None + self._sync_thread: Thread = None + self._capture_thread: Thread = None + self._trigger_thread: Thread = None + self._job: CaptureJob = None + self._device_is_running: bool = None + self._flag_execute_job: Event = None + + # initialize private properties. + # currently only picamera2 and gpio backend are supported, may be extended in the future + self._camera_backend: Picamera2Backend = Picamera2Backend(appconfig.backend_picamera2) + self._gpio_backend: GpioSecondaryNodeService = GpioSecondaryNodeService(appconfig.backend_gpio) + self._flag_execute_job: Event = Event() + + def start(self): + super().start() + + self._gpio_backend.start() + + self._supervisor_thread = Thread(name="_supervisor_thread", target=self._supervisor_fun, args=(), daemon=True) + self._supervisor_thread.start() + + logger.debug(f"{self.__module__} started") + + def stop(self): + super().stop() + + self._gpio_backend.stop() + + logger.debug(f"{self.__module__} stopped") + + def gen_stream(self): + """ + yield jpeg images to stream to client (if not created otherwise) + this function may be overriden by backends, but this is the default one + relies on the backends implementation of _wait_for_lores_image to return a buffer + """ + logger.info("livestream requested") + self._camera_backend.start_stream() + + if not self._device_is_running or not self._is_running: + raise RuntimeError("device not started, cannot deliver stream") + + while self._is_running and self._device_is_running: + try: + output_jpeg_bytes = self._camera_backend.wait_for_lores_image() + except StopIteration: + logger.info("stream ends due to shutdown acquisitionservice") + self._camera_backend.stop_stream() + return + except Exception as exc: + # this error probably cannot recover. + logger.exception(exc) + logger.error(f"streaming exception: {exc}") + self._camera_backend.stop_stream() + raise RuntimeError(f"Stream error {exc}") from exc + + yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + output_jpeg_bytes + b"\r\n\r\n") + + self._camera_backend.stop_stream() + + def setup_job(self, job: CaptureJob): + self._job = job + + def execute_job(self): + self._flag_execute_job.set() + + def _device_start(self, derived_fps: int): + self._device_is_running = True + + self._camera_backend.start(nominal_framerate=derived_fps) + + self._sync_thread = Thread(name="_sync_thread", target=self._sync_fun, args=(), daemon=True) + self._sync_thread.start() + self._capture_thread = Thread(name="_capture_thread", target=self._capture_fun, args=(), daemon=True) + self._capture_thread.start() + self._trigger_thread = Thread(name="_trigger_thread", target=self._trigger_fun, args=(), daemon=True) + self._trigger_thread.start() + + def _device_stop(self): + self._device_is_running = False + + self._camera_backend.stop() + + def _wait_for_clock(self, timeout: float = 2.0): + assert self._is_running is True # ensure to never call this function when not already started. + + while self._is_running: + try: + if self._gpio_backend.wait_for_clock_rise_signal(timeout=timeout): + logger.info("clock signal received, continue...") + break + except TimeoutError: + logger.info("waiting for clock signal in...") + except Exception as exc: + logger.exception(exc) + logger.error("unexpected error while waiting for sync clock in") + + def _supervisor_fun(self): + logger.info("device supervisor started, checking for clock, then starting device") + while self._is_running: + if not self._device_is_running: + self._wait_for_clock() + logger.info("got it, continue starting...") + + logger.info("deriving nominal framerate from clock signal, counting 10 ticks...") + derived_fps = self._gpio_backend.derive_nominal_framerate_from_clock() + logger.info(f"got it, derived {derived_fps}fps...") + + self._device_start(derived_fps) + else: + time.sleep(1) + + logger.info("device supervisor exit, stopping devices") + self._device_stop() + logger.info("device supervisor exit, stopped devices") + + def _sync_fun(self): + while self._device_is_running: + try: + timestamp_ns = self._gpio_backend.wait_for_clock_rise_signal(timeout=1) + self._camera_backend.sync_tick(timestamp_ns) + except TimeoutError: + # stop devices when no clock is avail, supervisor enables again after clock is received, derives new framerate ans starts backends + self._device_stop() + + def _capture_fun(self): + while self._device_is_running: + self._gpio_backend.wait_for_trigger_signal(timeout=None) + + # useful if mobile camera is without any interconnection to a concentrator that could setup a job + if self._config.allow_standalone_job: + logger.info("using default capture job") + self._job = CaptureJob() + + if self._job: + self._camera_backend.do_capture(self._job.id, self._job.number_captures) + self._job = None + else: + logger.warning("capture request ignored because no job set!") + + def _trigger_fun(self): + while self._device_is_running: + # wait until execute job is requested + if self._flag_execute_job.wait(timeout=1): + # first clear to avoid endless loops + self._flag_execute_job.clear() + # timeout=anything so it doesnt block shutdown. If flag is set during timeout it will be catched during next run and is not lost + # there is a job that shall be processed, now wait until we get a falling clock + # timeout not None (to avoid blocking) but longer than any frame could ever take + self._gpio_backend.wait_for_clock_fall_signal(timeout=1) + # clock is fallen, this is the sync point to send out trigger to other clients. chosen to send on falling clock because capture + # shall be on rising clock and this is with 1/2 frame distance far enough that all clients can prepare to capture + self._gpio_backend._trigger_out.on() # clients detect rising edge on trigger_in and invoke capture. + # now we wait until next falling clock and turn off the trigger + # timeout not None (to avoid blocking) but longer than any frame could ever take + self._gpio_backend.wait_for_clock_fall_signal(timeout=1) + self._gpio_backend._trigger_out.off() + # done, restart waiting for flag... + else: + pass + # just timed out, nothing to take care about. diff --git a/node/services/sync_aquisition_service.py b/node/services/sync_aquisition_service.py deleted file mode 100644 index 055b6c6..0000000 --- a/node/services/sync_aquisition_service.py +++ /dev/null @@ -1,119 +0,0 @@ -import logging -from threading import Thread - -from ..config import appconfig -from .backends.cameras.picamera2backend import Picamera2Backend -from .backends.io.gpio_secondary_node import GpioSecondaryNodeService -from .baseservice import BaseService - -logger = logging.getLogger(__name__) - - -class SyncedAcquisitionService(BaseService): - def __init__(self): - super().__init__() - - # init the arguments - pass - - # define private props - # to sync, a camera backend and io backend is used. - self._camera_backend: Picamera2Backend = None - self._gpio_backend: GpioSecondaryNodeService = None - self._sync_thread: Thread = None - self._capture_thread: Thread = None - - # initialize private properties. - # currently only picamera2 and gpio backend are supported, may be extended in the future - self._camera_backend: Picamera2Backend = Picamera2Backend(appconfig.picamera2) - self._gpio_backend: GpioSecondaryNodeService = GpioSecondaryNodeService(appconfig.secondary_gpio) - - def start(self): - super().start() - - self._gpio_backend.start() - - print("waiting for clock input, startup of service on halt!") - self._wait_for_clock() - print("got it, continue starting...") - - print("deriving nominal framerate from clock signal, counting 10 ticks...") - derived_fps = self._gpio_backend.derive_nominal_framerate_from_clock() - print(f"got it, derived {derived_fps}fps...") - - self._camera_backend.start(nominal_framerate=derived_fps) - - self._sync_thread = Thread(name="_sync_thread", target=self._sync_fun, args=(), daemon=True) - self._sync_thread.start() - self._capture_thread = Thread(name="_capture_thread", target=self._capture_fun, args=(), daemon=True) - self._capture_thread.start() - - logger.debug(f"{self.__module__} started") - - def stop(self): - super().stop() - - # self._capture_thread.stop() - # self._sync_thread.stop() - self._camera_backend.stop() - self._gpio_backend.stop() - - logger.debug(f"{self.__module__} stopped") - - def gen_stream(self): - """ - yield jpeg images to stream to client (if not created otherwise) - this function may be overriden by backends, but this is the default one - relies on the backends implementation of _wait_for_lores_image to return a buffer - """ - print("livestream started on backend") - self._camera_backend.start_stream() - - while self._is_running: - try: - output_jpeg_bytes = self._camera_backend.wait_for_lores_image() - except StopIteration: - logger.info("stream ends due to shutdown aquisitionservice") - self._camera_backend.stop_stream() - return - except Exception as exc: - # this error probably cannot recover. - logger.exception(exc) - logger.error(f"streaming exception: {exc}") - self._camera_backend.stop_stream() - raise RuntimeError(f"Stream error {exc}") from exc - - yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + output_jpeg_bytes + b"\r\n\r\n") - - self._camera_backend.stop_stream() - - def _wait_for_clock(self): - assert self._is_running is True # ensure to never call this function when not already started. - - while self._is_running: - try: - if self._gpio_backend.wait_for_clock_signal(timeout=2.0): - print("clock signal received, continue...") - break - except TimeoutError: - print("waiting for clock signal in...") - except Exception as e: - print(e) - print("unexpected error while waiting for sync clock in") - - def _sync_fun(self): - while self._is_running: - try: - timestamp_ns = self._gpio_backend.wait_for_clock_signal(timeout=1) - self._camera_backend.sync_tick(timestamp_ns) - except TimeoutError: - self._wait_for_clock() - # TODO: implement some kind of going to standby and stop also camera to save energy... - - def _capture_fun(self): - while self._is_running: - self._gpio_backend.wait_for_trigger_signal(timeout=None) - if self._gpio_backend.clock_signal_valid(): - self._camera_backend.do_capture() - else: - print("capture request ignored because no valid clock signal")