From 7e729618de953053315ab7a44fd3afd5fdd2b7f6 Mon Sep 17 00:00:00 2001 From: Jojojoppe Date: Mon, 28 Jul 2025 16:03:34 +0200 Subject: [PATCH] Camera connection added --- global_state.py | 3 + main.py => negstation.py | 1 + negstation_layout.ini | 37 +++++++++ negstation_widgets.json | 14 ++++ ui.py | 8 +- widgets/base_widget.py | 6 +- widgets/camcontrol_widget.py | 157 +++++++++++++++++++++++++++++++++++ widgets/simple_widget.py | 10 --- 8 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 global_state.py rename main.py => negstation.py (72%) mode change 100644 => 100755 create mode 100644 negstation_layout.ini create mode 100644 negstation_widgets.json create mode 100644 widgets/camcontrol_widget.py delete mode 100644 widgets/simple_widget.py diff --git a/global_state.py b/global_state.py new file mode 100644 index 0000000..9a638d2 --- /dev/null +++ b/global_state.py @@ -0,0 +1,3 @@ +class GlobalState: + def __init__(self): + pass \ No newline at end of file diff --git a/main.py b/negstation.py old mode 100644 new mode 100755 similarity index 72% rename from main.py rename to negstation.py index 5f3b837..e3bdaa4 --- a/main.py +++ b/negstation.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import ui if __name__=="__main__": diff --git a/negstation_layout.ini b/negstation_layout.ini new file mode 100644 index 0000000..4ac5569 --- /dev/null +++ b/negstation_layout.ini @@ -0,0 +1,37 @@ +[Window][WindowOverViewport_11111111] +Pos=0,19 +Size=1280,701 +Collapsed=0 + +[Window][###28] +Pos=321,420 +Size=959,300 +Collapsed=0 +DockId=0x00000002,0 + +[Window][###30] +Pos=0,19 +Size=319,701 +Collapsed=0 +DockId=0x0000000B,0 + +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Docking][Data] +DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1280,701 Split=X + DockNode ID=0x0000000B Parent=0x7C6B3D9B SizeRef=319,701 Selected=0x5F94F9BD + DockNode ID=0x0000000C Parent=0x7C6B3D9B SizeRef=959,701 Split=X + DockNode ID=0x00000009 Parent=0x0000000C SizeRef=319,701 Selected=0x5F94F9BD + DockNode ID=0x0000000A Parent=0x0000000C SizeRef=959,701 Split=X + DockNode ID=0x00000007 Parent=0x0000000A SizeRef=340,701 Selected=0x5F94F9BD + DockNode ID=0x00000008 Parent=0x0000000A SizeRef=938,701 Split=X + DockNode ID=0x00000005 Parent=0x00000008 SizeRef=300,701 Selected=0x5F94F9BD + DockNode ID=0x00000006 Parent=0x00000008 SizeRef=978,701 Split=X + DockNode ID=0x00000003 Parent=0x00000006 SizeRef=318,701 Selected=0x5F94F9BD + DockNode ID=0x00000004 Parent=0x00000006 SizeRef=960,701 Split=Y + DockNode ID=0x00000001 Parent=0x00000004 SizeRef=979,399 CentralNode=1 + DockNode ID=0x00000002 Parent=0x00000004 SizeRef=979,300 Selected=0xA4B861D9 + diff --git a/negstation_widgets.json b/negstation_widgets.json new file mode 100644 index 0000000..4ab4e08 --- /dev/null +++ b/negstation_widgets.json @@ -0,0 +1,14 @@ +[ + { + "widget_type": "LogWidget", + "config": { + "label": "LogWidget" + } + }, + { + "widget_type": "CamControlWidget", + "config": { + "label": "CamControlWidget" + } + } +] \ No newline at end of file diff --git a/ui.py b/ui.py index 950f417..756843f 100644 --- a/ui.py +++ b/ui.py @@ -6,6 +6,7 @@ import importlib import inspect from collections import deque +import global_state from widgets.base_widget import BaseWidget class DpgLogHandler(logging.Handler): @@ -29,6 +30,8 @@ class LayoutManager: def __init__(self): self.active_widgets = {} self.widget_classes = {} + self.updating_widgets = [] + self.global_state = global_state.GlobalState() def discover_and_register_widgets(self, directory="widgets"): """Dynamically discovers and registers widgets from a given directory.""" @@ -63,7 +66,7 @@ class LayoutManager: return config = {"label": widget_type} WidgetClass = self.widget_classes[widget_type] - widget_instance = WidgetClass(widget_type, config, self) + widget_instance = WidgetClass(widget_type, config, self, self.global_state) logging.info(f"Creating new widget of type: {widget_type}") self.active_widgets[widget_type] = widget_instance widget_instance.create() @@ -87,6 +90,9 @@ class LayoutManager: if "LogWidget" in self.active_widgets: # We need to pass the handler to the update method self.active_widgets["LogWidget"].update_logs(log_handler) + for w in self.updating_widgets: + if w in self.active_widgets: + self.active_widgets[w].update() @staticmethod def run(): diff --git a/widgets/base_widget.py b/widgets/base_widget.py index 35ced31..482ed54 100644 --- a/widgets/base_widget.py +++ b/widgets/base_widget.py @@ -3,10 +3,11 @@ import logging class BaseWidget: """A base class to handle common functionality for all widgets.""" - def __init__(self, widget_type: str, config: dict, layout_manager): + def __init__(self, widget_type: str, config: dict, layout_manager, global_state): self.widget_type = widget_type self.config = config self.layout_manager = layout_manager + self.global_state = global_state self.window_tag = f"widget_win_{self.widget_type}" def create(self): @@ -15,6 +16,9 @@ class BaseWidget: def get_config(self) -> dict: return self.config + def update(self): + raise NotImplementedError + def _on_window_close(self, sender, app_data, user_data): logging.info(f"Hiding widget: {self.widget_type}") dpg.configure_item(self.window_tag, show=False) \ No newline at end of file diff --git a/widgets/camcontrol_widget.py b/widgets/camcontrol_widget.py new file mode 100644 index 0000000..397ce1a --- /dev/null +++ b/widgets/camcontrol_widget.py @@ -0,0 +1,157 @@ +# in widgets/camera_widget.py +import dearpygui.dearpygui as dpg +import gphoto2 as gp +import logging +import threading +import queue + +from .base_widget import BaseWidget + +# Set up a logger specific to this widget for clear debugging +logger = logging.getLogger(__name__) +logger.setLevel("DEBUG") + +class CamControlWidget(BaseWidget): + def __init__(self, widget_type: str, config: dict, layout_manager, app_state): + super().__init__(widget_type, config, layout_manager, app_state) + layout_manager.updating_widgets.append("CamControlWidget") + + # A dictionary to map user-friendly camera names to technical port strings + self.camera_map = {} + + # Queues for communicating with the worker thread + self.command_queue = queue.Queue() + self.result_queue = queue.Queue() + + # Start the background worker thread. It's a daemon so it exits with the app. + self.worker_thread = threading.Thread(target=self._camera_worker, daemon=True) + self.worker_thread.start() + + def create(self): + if dpg.does_item_exist(self.window_tag): return + + with dpg.window(label="Camera Control", tag=self.window_tag, on_close=self._on_window_close, width=320, height=160): + + # This group contains all controls visible when disconnected + with dpg.group(tag=f"connection_group_{self.widget_type}") as self.connection_group_tag: + dpg.add_button(label="Detect Cameras", callback=lambda: self.command_queue.put(('DETECT', None)), width=-1) + with dpg.group(horizontal=True): + self.camera_combo_tag = dpg.add_combo(label="Camera", items=[], width=-115) + + def connect_callback(): + selected_name = dpg.get_value(self.camera_combo_tag) + if selected_name and selected_name in self.camera_map: + port = self.camera_map[selected_name] + self.command_queue.put(('CONNECT', port)) + else: + logger.warning("No camera selected to connect to.") + + self.connect_button = dpg.add_button(label="Connect", callback=connect_callback) + + # This button is only visible when connected + self.disconnect_button_tag = dpg.add_button( + label="Disconnect", + callback=lambda: self.command_queue.put(('DISCONNECT', None)), + show=False, + width=-1 + ) + + self.status_text_tag = dpg.add_text("Status: Disconnected") + + def update(self): + try: + # Check for a result from the worker thread without blocking + result_type, data = self.result_queue.get_nowait() + + if result_type == 'STATUS': + dpg.set_value(self.status_text_tag, f"Status: {data}") + + elif result_type == 'CAMERAS_DETECTED': + # data is a list of (name, port) tuples + self.camera_map = {name: port for name, port in data} + camera_names = list(self.camera_map.keys()) + dpg.configure_item(self.camera_combo_tag, items=camera_names) + if camera_names: + dpg.set_value(self.camera_combo_tag, camera_names[0]) + + elif result_type == 'CONNECTED' and data is True: + # Hide connection controls and show the disconnect button + dpg.configure_item(self.connection_group_tag, show=False) + dpg.configure_item(self.disconnect_button_tag, show=True) + + elif result_type == 'DISCONNECTED' and data is True: + # Hide disconnect button and show connection controls + dpg.configure_item(self.connection_group_tag, show=True) + dpg.configure_item(self.disconnect_button_tag, show=False) + + except queue.Empty: + # This is expected when there are no new results from the worker + pass + + def _camera_worker(self): + camera = None + logger.info("Camera worker thread started.") + + while True: + try: + command, args = self.command_queue.get() + logger.info(f"Camera worker received command: {command}") + + if command == 'DETECT': + try: + # Autodetect returns a list of (name, port) tuples + camera_list = list(gp.Camera.autodetect()) + logger.debug(f"Cameras found: {str(camera_list)}") + if not camera_list: + self.result_queue.put(('STATUS', 'No cameras found.')) + self.result_queue.put(('CAMERAS_DETECTED', [])) + continue + + self.result_queue.put(('CAMERAS_DETECTED', camera_list)) + self.result_queue.put(('STATUS', f'Detected {len(camera_list)} camera(s).')) + except Exception as e: + logger.error(f"Error during camera detection: {e}") + self.result_queue.put(('STATUS', 'Error during detection.')) + + elif command == 'CONNECT': + if camera is not None: + self.result_queue.put(('STATUS', 'A camera is already connected.')) + continue + + port = args + if not port: + self.result_queue.put(('STATUS', 'No camera port selected.')) + continue + + try: + camera = gp.Camera() + port_info_list = gp.PortInfoList(); port_info_list.load() + port_index = port_info_list.lookup_path(port) + camera.set_port_info(port_info_list[port_index]) + + camera.init() + self.result_queue.put(('STATUS', f"Connected successfully!")) + self.result_queue.put(('CONNECTED', True)) + except gp.GPhoto2Error as ex: + camera = None # Ensure camera is None on failure + logger.error(f"GPhoto2 Error on connect: {ex}") + self.result_queue.put(('STATUS', f"Error: {ex}")) + self.result_queue.put(('CONNECTED', False)) + + elif command == 'DISCONNECT': + if camera: + try: + camera.exit() + camera = None # Critical to update state + self.result_queue.put(('STATUS', 'Disconnected.')) + self.result_queue.put(('DISCONNECTED', True)) + except gp.GPhoto2Error as ex: + logger.error(f"Error on disconnect: {ex}") + self.result_queue.put(('STATUS', f"Error on disconnect: {ex}")) + else: + self.result_queue.put(('STATUS', 'Already disconnected.')) + + except Exception as e: + # Broad exception to catch any other errors in the worker loop + logger.error(f"An unexpected exception occurred in the camera worker: {e}") + self.result_queue.put(('STATUS', f'Worker Error: {e}')) \ No newline at end of file diff --git a/widgets/simple_widget.py b/widgets/simple_widget.py deleted file mode 100644 index 81e7d91..0000000 --- a/widgets/simple_widget.py +++ /dev/null @@ -1,10 +0,0 @@ -import dearpygui.dearpygui as dpg -from .base_widget import BaseWidget - -class SimpleWidget(BaseWidget): - """A basic text widget to demonstrate dynamic loading.""" - def create(self): - if dpg.does_item_exist(self.window_tag): return - with dpg.window(label="Simple Widget", tag=self.window_tag, on_close=self._on_window_close, width=300, height=120): - dpg.add_text("This widget was loaded dynamically!") - dpg.add_button(label="A Button") \ No newline at end of file