From 00bc8be0fca1961fd37c97277e38fab969564c05 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Thu, 23 Oct 2025 16:49:45 +0300 Subject: [PATCH 01/10] Add Raspberry Pi 5 support with lgpio integration - Migrate RPPorts interface to Pi 5 compatible implementation using RPi.GPIO (lgpio backend) - Add Pi 5 compatible display initialization with auto-detection and frame buffer support in calibration and welcome screens - Improve pygame display handling with fullscreen support, proper scaling, and FPS control --- src/ethopy/experiments/calibrate.py | 442 ++++++++++++++++++---------- src/ethopy/interfaces/RPPorts5.py | 400 +++++++++++++++++++++++++ src/ethopy/utils/start.py | 158 +++++++--- 3 files changed, 812 insertions(+), 188 deletions(-) create mode 100644 src/ethopy/interfaces/RPPorts5.py diff --git a/src/ethopy/experiments/calibrate.py b/src/ethopy/experiments/calibrate.py index 6e51d70..39a013f 100644 --- a/src/ethopy/experiments/calibrate.py +++ b/src/ethopy/experiments/calibrate.py @@ -1,12 +1,12 @@ import logging import time +import os from importlib import import_module import pygame try: import pygame_menu - IMPORT_PYGAME_MENU = True except ImportError: IMPORT_PYGAME_MENU = False @@ -15,9 +15,10 @@ class Experiment: - """_summary_ - I created a main menu where every time i want to move to new one a clean it - and i render the new components + """Calibration experiment with Pi 5 compatibility + + Main menu where every time we want to move to new one, clean it + and render the new components Menu order: 1. pressure menu: define the air pressure in PSI @@ -28,30 +29,39 @@ class Experiment: """ def __init__(self): - # self.interface = None self.params = None self.logger = None self.sync = False self.cal_idx = 0 self.msg = "" self.pulse = 0 + + # Screen dimensions - will be auto-detected self.screen_width = 800 self.screen_height = 480 + self.ports = None self.port = None + self.interface = None + self.screen = None + self.menu = None + self.theme = None + + # Pi 5 compatibility flags + self.is_fullscreen = False + self.display_scale = 1.0 + if not globals()["IMPORT_PYGAME_MENU"]: raise ImportError( "You need to install the pygame-menu: pip install pygame-menu" ) def setup(self, logger, params): - """setup _summary_ - - _extended_summary_ - """ + """Setup experiment with Pi 5 compatibility""" self.params = params self.logger = logger + # Get interface configuration interface_module = self.logger.get( schema="interface", table="SetupConfiguration", @@ -63,25 +73,106 @@ def setup(self, logger, params): ) self.setup_conf_idx = self.params["setup_conf_idx"] - self.interface = interface(exp=self, callbacks=False) + # Initialize interface (this will use our Pi 5 compatible RPPorts) + try: + self.interface = interface(exp=self, callbacks=False) + log.info("Interface initialized successfully") + except Exception as e: + log.error(f"Failed to initialize interface: {e}") + raise + + # Initialize pygame with Pi 5 compatibility + self._init_pygame() + + # Setup pygame menu theme + self._setup_theme() + + # Create main menu + self._create_main_menu() + + # Initialize calibration variables + self.pressure = None + self.curr = "" + self.stop = False + + # Start with the pressure menu + self.create_pressure_menu() + + # Run the experiment + self.run() - pygame.init() - self.screen = pygame.display.set_mode((800, 480)) - if self.logger.is_pi: - self.screen = pygame.display.set_mode( - (self.screen_width, self.screen_height), pygame.FULLSCREEN - ) + def _init_pygame(self): + """Initialize pygame with Pi 5 compatibility""" + if not pygame.get_init(): + pygame.init() + + # Auto-detect screen resolution for Pi 5 + try: + info = pygame.display.Info() + detected_width = info.current_w + detected_height = info.current_h + + log.info(f"Detected screen resolution: {detected_width}x{detected_height}") + + # Use detected resolution if reasonable, otherwise fallback + if detected_width > 400 and detected_height > 300: + self.screen_width = detected_width + self.screen_height = detected_height + else: + log.warning("Using fallback resolution 800x480") + + except Exception as e: + log.warning(f"Could not detect screen resolution: {e}") - # Configure self.theme + # Calculate scaling factor for UI elements + self.display_scale = min(self.screen_width / 800, self.screen_height / 480) + + # Set display mode with Pi 5 compatibility + try: + if self.logger.is_pi: + # Try fullscreen mode first + self.screen = pygame.display.set_mode( + (self.screen_width, self.screen_height), + pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE + ) + self.is_fullscreen = True + log.info("Fullscreen mode activated") + + # Hide mouse cursor for kiosk mode + pygame.mouse.set_visible(False) + + else: + # Windowed mode for development + self.screen = pygame.display.set_mode((self.screen_width, self.screen_height)) + log.info("Windowed mode activated") + + except pygame.error as e: + log.error(f"Failed to set display mode: {e}") + # Fallback to windowed mode + self.screen = pygame.display.set_mode((800, 480)) + self.screen_width = 800 + self.screen_height = 480 + self.display_scale = 1.0 + log.info("Fallback to 800x480 windowed mode") + + pygame.display.set_caption("EthoPy Calibration") + + def _setup_theme(self): + """Setup pygame menu theme with scaling""" self.theme = pygame_menu.themes.THEME_DARK.copy() self.theme.background_color = (0, 0, 0) self.theme.title_background_color = (43, 43, 43) - self.theme.title_font_size = 35 + + # Scale font sizes based on display + self.theme.title_font_size = int(35 * self.display_scale) + self.theme.widget_font_size = int(30 * self.display_scale) + self.theme.widget_alignment = pygame_menu.locals.ALIGN_CENTER self.theme.widget_font_color = (255, 255, 255) - self.theme.widget_font_size = 30 self.theme.widget_padding = 0 + def _create_main_menu(self): + """Create the main menu with proper dimensions""" self.menu = pygame_menu.Menu( "", self.screen_width, @@ -93,89 +184,126 @@ def setup(self, logger, params): theme=self.theme, ) - self.pressure = None - self.curr = "" - self.stop = False - # Start with the pressure menu - - self.create_pressure_menu() - self.run() - def run(self) -> None: - """ - Calibration mainloop. - """ + """Calibration mainloop with improved error handling""" + clock = pygame.time.Clock() # Add FPS control + try: - while not self.stop: # Changed from self.stop == False for better style + while not self.stop: events = pygame.event.get() for event in events: if event.type == pygame.QUIT: self.stop = True break - - if self.menu.is_enabled() and not self.stop: # Added stop check - self.menu.update(events) + # Add escape key to exit fullscreen/application + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self.stop = True + break + elif event.key == pygame.K_F11 and not self.logger.is_pi: + # Toggle fullscreen in development mode + self._toggle_fullscreen() + + if self.menu and self.menu.is_enabled() and not self.stop: try: + self.menu.update(events) + # Clear screen before drawing + self.screen.fill((0, 0, 0)) self.menu.draw(self.screen) - pygame.display.flip() - except pygame.error: - # Display was probably quit, exit gracefully + pygame.display.flip() # Use flip() for better performance + except pygame.error as e: + log.error(f"Display error: {e}") break + + # Limit FPS to reduce CPU usage + clock.tick(60) + + except KeyboardInterrupt: + log.info("Keyboard interrupt received") + except Exception as e: + log.error(f"Unexpected error in main loop: {e}") finally: self.cleanup() + def _toggle_fullscreen(self): + """Toggle fullscreen mode (development only)""" + try: + if self.is_fullscreen: + self.screen = pygame.display.set_mode((800, 480)) + self.is_fullscreen = False + pygame.mouse.set_visible(True) + else: + self.screen = pygame.display.set_mode( + (self.screen_width, self.screen_height), pygame.FULLSCREEN + ) + self.is_fullscreen = True + pygame.mouse.set_visible(False) + except Exception as e: + log.error(f"Failed to toggle fullscreen: {e}") + def cleanup(self): - """Cleanup pygame and interface resources.""" + """Cleanup pygame and interface resources with better error handling""" + log.info("Starting cleanup...") + + # Cleanup pygame if pygame.get_init(): try: - # Clear any remaining events pygame.event.clear() - - if hasattr(self, "menu"): + + if hasattr(self, "menu") and self.menu: self.menu.disable() - + + pygame.mouse.set_visible(True) # Show cursor before exit pygame.display.quit() pygame.quit() + log.info("Pygame cleaned up successfully") except Exception as e: log.warning(f"Error during pygame cleanup: {e}") - if hasattr(self, "interface"): + # Cleanup interface + if hasattr(self, "interface") and self.interface: try: self.interface.cleanup() + log.info("Interface cleaned up successfully") except Exception as e: log.warning(f"Error during interface cleanup: {e}") - if hasattr(self, "logger"): + # Update logger status + if hasattr(self, "logger") and self.logger: try: self.logger.update_setup_info({"status": "ready"}) + log.info("Logger status updated") except Exception as e: log.warning(f"Error updating logger status: {e}") def exit(self): - """exit _summary_ - - exit function after the Experiment has finished - """ + """Exit function after the Experiment has finished""" try: - self.menu.clear() - self.menu.add.label("Done calibrating!!", float=True, font_size=30).translate(20, 80) - try: - self.menu.draw(self.screen) - pygame.display.flip() - time.sleep(2) - except pygame.error: - pass # Display might already be quit + if self.menu: + self.menu.clear() + # Scale exit message font + exit_font_size = int(30 * self.display_scale) + self.menu.add.label( + "Done calibrating!!", + float=True, + font_size=exit_font_size + ).translate(int(20 * self.display_scale), int(80 * self.display_scale)) + + try: + self.screen.fill((0, 0, 0)) + self.menu.draw(self.screen) + pygame.display.flip() + time.sleep(2) + except pygame.error: + pass # Display might already be quit self.stop = True except Exception as e: log.warning(f"Error during exit: {e}") self.stop = True - self.interface.cleanup() - self.logger.update_setup_info({"status": "ready"}) - time.sleep(1) def create_pressure_menu(self): - """The First menu in Calibration where is definde the air pressure in PSI""" + """The First menu in Calibration where air pressure in PSI is defined""" self.menu.clear() self.button_input("Enter air pressure (PSI)", self.create_pulsenum_menu) @@ -185,9 +313,17 @@ def create_pulsenum_menu(self): self.curr = "" if self.cal_idx < len(self.params["pulsenum"]): self.menu.clear() + + # Scale UI elements + label_font_size = int(30 * self.display_scale) + button_font_size = int(30 * self.display_scale) + self.menu.add.label( - "Place zero-weighted pad under the port", float=True, font_size=30 - ).translate(20, 80) + "Place zero-weighted pad under the port", + float=True, + font_size=label_font_size + ).translate(int(20 * self.display_scale), int(80 * self.display_scale)) + self.menu.add.button( "OK", self.create_pulse_num, @@ -195,66 +331,64 @@ def create_pulsenum_menu(self): float=True, padding=(10, 10, 10, 10), background_color=(0, 128, 0), - font_size=30, - ).translate(400, 140) + font_size=button_font_size, + ).translate(int(400 * self.display_scale), int(140 * self.display_scale)) else: self.exit() def create_pulse_num(self): - """ - Display the pulses - """ + """Display the pulses""" self.pulse = 0 msg = f"Pulse {self.pulse + 1}/{self.params['pulsenum'][self.cal_idx]}" self.menu.clear() + + # Scale pulse label + pulse_font_size = int(40 * self.display_scale) + pulses_label = self.menu.add.label( msg, float=True, label_id="pulses_label", - font_size=40, + font_size=pulse_font_size, background_color=(0, 15, 15), - ).translate(0, 50) - # Adds a function to the Widget to be executed each time the label is drawn. + ).translate(0, int(50 * self.display_scale)) + + # Adds a function to the Widget to be executed each time the label is drawn pulses_label.add_draw_callback(self.run_pulses) def run_pulses(self, widget, menu): - """This function is executed each time the label is drawm - - Args: - widget (_type_): The widget that uses the function - menu (_type_): The current menu - """ + """This function is executed each time the label is drawn""" if self.pulse < self.params["pulsenum"][self.cal_idx]: self.msg = f"Pulse {self.pulse + 1}/{self.params['pulsenum'][self.cal_idx]}" log.info(f"\r{self.msg}") widget.set_title(self.msg) + for port in self.params["ports"]: try: self.interface.give_liquid( port, self.params["duration"][self.cal_idx] ) - pass except Exception as error: - # ToDo update notes in control table - log.info(f"Calibration Error {error}") + log.error(f"Calibration Error: {error}") self.exit() + return time.sleep( self.params["duration"][self.cal_idx] / 1000 + self.params["pulse_interval"][self.cal_idx] / 1000 ) - self.pulse += 1 # update trial + self.pulse += 1 else: self.cal_idx += 1 self.ports = self.params["ports"].copy() self.create_port_weight() def create_port_weight(self): - """A menu with numpad for defining the water in every port""" + """A menu with numpad for defining the water weight in every port""" self.menu.clear() cal_idx = self.cal_idx - 1 + if self.params["save"]: - self.menu.clear() if len(self.ports) != 0: if len(self.ports) != len(self.params["ports"]): self.log_pulse_weight( @@ -267,7 +401,7 @@ def create_port_weight(self): self.port = self.ports.pop(0) self.button_input( - f"Enter weight for port {self.port }", self.create_port_weight + f"Enter weight for port {self.port}", self.create_port_weight ) else: self.log_pulse_weight( @@ -277,34 +411,18 @@ def create_port_weight(self): self.curr, self.pressure, ) - self.create_pulsenum_menu() else: self.create_pulsenum_menu() def button_input(self, message: str, _func): - """button_input _summary_ - - Create a label with a numpad - - Args: - message (str): a string to display in as label - _func (method): a method to run after the OK is pressed in the numpad - """ - self.menu.add.label( - message, - font_size=25, - ) + """Create a label with a numpad""" + label_font_size = int(25 * self.display_scale) + self.menu.add.label(message, font_size=label_font_size) self.num_pad(_func) def num_pad(self, _func): - """num_pad _summary_ - - _extended_summary_ - - Args: - log_function (_type_): _description_ - """ + """Create numpad with scaling for different screen sizes""" self.num_pad_disp = self.menu.add.label( "", background_color=None, @@ -312,13 +430,18 @@ def num_pad(self, _func): selectable=True, selection_effect=None, ) - self.menu.add.vertical_margin(10) + self.menu.add.vertical_margin(int(10 * self.display_scale)) cursor = pygame_menu.locals.CURSOR_HAND - self.curr = "" - # Add horizontal frames - f1 = self.menu.add.frame_h(299, 54, margin=(0, 0)) + # Scale button dimensions + frame_width = int(299 * self.display_scale) + frame_height = int(54 * self.display_scale) + button_width = int(74 * self.display_scale) + button_height = int(54 * self.display_scale) + + # Add horizontal frames with scaled dimensions + f1 = self.menu.add.frame_h(frame_width, frame_height, margin=(0, 0)) b1 = f1.pack(self.menu.add.button("1", lambda: self._press(1), cursor=cursor)) b2 = f1.pack( self.menu.add.button("2", lambda: self._press(2), cursor=cursor), @@ -328,9 +451,9 @@ def num_pad(self, _func): self.menu.add.button("3", lambda: self._press(3), cursor=cursor), align=pygame_menu.locals.ALIGN_RIGHT, ) - self.menu.add.vertical_margin(5) + self.menu.add.vertical_margin(int(5 * self.display_scale)) - f2 = self.menu.add.frame_h(299, 54, margin=(0, 0)) + f2 = self.menu.add.frame_h(frame_width, frame_height, margin=(0, 0)) b4 = f2.pack(self.menu.add.button("4", lambda: self._press(4), cursor=cursor)) b5 = f2.pack( self.menu.add.button("5", lambda: self._press(5), cursor=cursor), @@ -340,9 +463,9 @@ def num_pad(self, _func): self.menu.add.button("6", lambda: self._press(6), cursor=cursor), align=pygame_menu.locals.ALIGN_RIGHT, ) - self.menu.add.vertical_margin(5) + self.menu.add.vertical_margin(int(5 * self.display_scale)) - f3 = self.menu.add.frame_h(299, 54, margin=(0, 0)) + f3 = self.menu.add.frame_h(frame_width, frame_height, margin=(0, 0)) b7 = f3.pack(self.menu.add.button("7", lambda: self._press(7), cursor=cursor)) b8 = f3.pack( self.menu.add.button("8", lambda: self._press(8), cursor=cursor), @@ -352,9 +475,9 @@ def num_pad(self, _func): self.menu.add.button("9", lambda: self._press(9), cursor=cursor), align=pygame_menu.locals.ALIGN_RIGHT, ) - self.menu.add.vertical_margin(5) + self.menu.add.vertical_margin(int(5 * self.display_scale)) - f4 = self.menu.add.frame_h(299, 54, margin=(0, 0)) + f4 = self.menu.add.frame_h(frame_width, frame_height, margin=(0, 0)) delete = f4.pack( self.menu.add.button("<", lambda: self._press("<"), cursor=cursor), align=pygame_menu.locals.ALIGN_RIGHT, @@ -367,30 +490,33 @@ def num_pad(self, _func): self.menu.add.button(" .", lambda: self._press("."), cursor=cursor), align=pygame_menu.locals.ALIGN_LEFT, ) - self.menu.add.vertical_margin(5) + self.menu.add.vertical_margin(int(5 * self.display_scale)) - f5 = self.menu.add.frame_h(299, 54, margin=(0, 0)) + f5 = self.menu.add.frame_h(frame_width, frame_height, margin=(0, 0)) ok = f5.pack( self.menu.add.button("OK", lambda: self._press("ok", _func), cursor=cursor), align=pygame_menu.locals.ALIGN_CENTER, ) - # Add decorator for each object + # Add decorator for each object with scaled dimensions + rect_offset_x = int(-37 * self.display_scale) + rect_offset_y = int(-27 * self.display_scale) + rect_width = int(button_width) + rect_height = int(button_height) + for widget in (b1, b2, b3, b4, b5, b6, b7, b8, b9, b0, ok, delete, dot): w_deco = widget.get_decorator() if widget != ok: - w_deco.add_rectangle(-37, -27, 74, 54, (15, 15, 15)) - on_layer = w_deco.add_rectangle(-37, -27, 74, 54, (84, 84, 84)) + w_deco.add_rectangle(rect_offset_x, rect_offset_y, rect_width, rect_height, (15, 15, 15)) + on_layer = w_deco.add_rectangle(rect_offset_x, rect_offset_y, rect_width, rect_height, (84, 84, 84)) else: - w_deco.add_rectangle(-37, -27, 74, 54, (0, 128, 0)) - on_layer = w_deco.add_rectangle(-37, -27, 74, 54, (40, 171, 187)) + w_deco.add_rectangle(rect_offset_x, rect_offset_y, rect_width, rect_height, (0, 128, 0)) + on_layer = w_deco.add_rectangle(rect_offset_x, rect_offset_y, rect_width, rect_height, (40, 171, 187)) w_deco.disable(on_layer) widget.set_attribute("on_layer", on_layer) def widget_select(sel: bool, wid: "pygame_menu.widgets.Widget", _): - """ - Function triggered if widget is selected - """ + """Function triggered if widget is selected""" lay = wid.get_attribute("on_layer") if sel: wid.get_decorator().enable(lay) @@ -401,44 +527,52 @@ def widget_select(sel: bool, wid: "pygame_menu.widgets.Widget", _): widget.set_padding((2, 19, 0, 23)) def _press(self, digit, _func=None) -> None: - """ - Press numpad digit. - - :param digit: Number or symbol - """ + """Press numpad digit""" if digit == "ok": - if not self.curr == "": + if self.curr != "": _func() elif digit == "<": self.curr = "" - self.num_pad_disp.set_title(str("")) + self.num_pad_disp.set_title("") else: if len(self.curr) <= 9: self.curr += str(digit) self.num_pad_disp.set_title(self.curr) def log_pulse_weight(self, pulse_dur, port, pulse_num, weight=0, pressure=0): - key = dict(setup=self.logger.setup, port=port, date=time.strftime("%Y-%m-%d")) - self.logger.put( - table="PortCalibration", - tuple=key, - schema="interface", - priority=5, - ignore_extra_fields=True, - validate=True, - block=True, - replace=False, - ) - self.logger.put( - table="PortCalibration.Liquid", - schema="interface", - replace=True, - ignore_extra_fields=True, - tuple=dict( - key, - pulse_dur=pulse_dur, - pulse_num=pulse_num, - weight=weight, - pressure=pressure, - ), - ) + """Log calibration data to database""" + try: + key = dict( + setup=self.logger.setup, + port=port, + date=time.strftime("%Y-%m-%d") + ) + + self.logger.put( + table="PortCalibration", + tuple=key, + schema="interface", + priority=5, + ignore_extra_fields=True, + validate=True, + block=True, + replace=False, + ) + + self.logger.put( + table="PortCalibration.Liquid", + schema="interface", + replace=True, + ignore_extra_fields=True, + tuple=dict( + key, + pulse_dur=pulse_dur, + pulse_num=pulse_num, + weight=weight, + pressure=pressure, + ), + ) + log.info(f"Logged calibration data for port {port}") + + except Exception as e: + log.error(f"Failed to log calibration data: {e}") \ No newline at end of file diff --git a/src/ethopy/interfaces/RPPorts5.py b/src/ethopy/interfaces/RPPorts5.py new file mode 100644 index 0000000..f3775bc --- /dev/null +++ b/src/ethopy/interfaces/RPPorts5.py @@ -0,0 +1,400 @@ +import logging +from concurrent.futures import ThreadPoolExecutor +from time import sleep +import threading + +import numpy as np + +from ethopy import local_conf +from ethopy.core.interface import Interface, Port +from ethopy.utils.helper_functions import convert_numeric_keys + +try: + from RPi import GPIO + import lgpio + + IMPORT_RP = True +except ImportError: + IMPORT_RP = False + +log = logging.getLogger(__name__) + + +class RPPorts5(Interface): + def __init__(self, **kwargs): + if not globals()["IMPORT_RP"]: + raise ImportError( + "Could not import RPi packages (rpi-lgpio)! " + "Please install: pip install rpi-lgpio" + ) + super(RPPorts5, self).__init__(**kwargs) + self.GPIO = GPIO + self.GPIO.setmode(self.GPIO.BCM) + + # Store the lgpio chip handle for tx_pulse + self.chip = GPIO._chip # Access the internal chip handle + + self.frequency = 15 + self.pulses = dict() + self.sound_pulses = [] + self.channels = convert_numeric_keys(local_conf.get("Channels")) + self.thread = ThreadPoolExecutor(max_workers=4) + + # For sound generation + self._sound_pwm = None + self._sound_stop_event = threading.Event() + + matched_ports = set(self.rew_ports) & set(self.channels["Liquid"].keys()) + assert matched_ports == set(self.rew_ports), ( + "All reward ports must have assigned a liquid delivery port!" + ) + + if "Lick" in self.channels: + self.GPIO.setup( + list(self.channels["Lick"].values()), + self.GPIO.IN, + pull_up_down=GPIO.PUD_DOWN, + ) + if self.callbacks: + for channel in self.channels["Lick"]: + self.GPIO.add_event_detect( + self.channels["Lick"][channel], + self.GPIO.RISING, + callback=self._lick_port_activated, + bouncetime=100, + ) + + if "Proximity" in self.channels: + self.GPIO.setup( + list(self.channels["Proximity"].values()), + self.GPIO.IN, + pull_up_down=GPIO.PUD_DOWN, + ) + if self.callbacks: + for channel in self.channels["Proximity"]: + self.GPIO.add_event_detect( + self.channels["Proximity"][channel], + self.GPIO.BOTH, + callback=self._position_change, + bouncetime=50, + ) + + if "Odor" in self.channels: + self.GPIO.setup( + list(self.channels["Odor"].values()), + self.GPIO.OUT, + initial=self.GPIO.LOW, + ) + + if "Opto" in self.channels: + self.GPIO.setup(self.channels["Opto"], self.GPIO.OUT, initial=self.GPIO.LOW) + + if "Liquid" in self.channels: + for channel in self.channels["Liquid"]: + # Set up liquid delivery pins using RPi.GPIO + self.GPIO.setup( + self.channels["Liquid"][channel], + self.GPIO.OUT, + initial=self.GPIO.LOW, + ) + + if "Sound" in self.channels: + for channel in self.channels["Sound"]: + # Set up sound pins using RPi.GPIO + self.GPIO.setup( + self.channels["Sound"][channel], + self.GPIO.OUT, + initial=self.GPIO.LOW, + ) + + if "Status" in self.channels: + self.GPIO.setup( + self.channels["Status"], self.GPIO.OUT, initial=self.GPIO.LOW + ) + + if "Sync" in self.channels and "out" in self.channels["Sync"]: + self.GPIO.setup( + self.channels["Sync"]["out"], self.GPIO.OUT, initial=self.GPIO.LOW + ) + + if self.exp.sync: + self.GPIO.setup( + self.channels["Sync"]["rec"], self.GPIO.IN, pull_up_down=GPIO.PUD_DOWN + ) + self.GPIO.setup( + self.channels["Sync"]["in"], self.GPIO.IN, pull_up_down=GPIO.PUD_DOWN + ) + self.GPIO.add_event_detect( + self.channels["Sync"]["in"], + self.GPIO.BOTH, + callback=self._sync_in, + bouncetime=20, + ) + self.dataset = self.logger.createDataset( + dataset_name="sync", dataset_type=np.dtype([("sync_times", np.double)]) + ) + + def give_liquid(self, port, duration=False): + if not duration: + duration = self.duration[port] + self.thread.submit(self._give_pulse, port, duration) + + def give_odor(self, delivery_port, odor_id, odor_duration, dutycycle): + for i, _ in enumerate(odor_id): + self.thread.submit( + self._pwd_out, + self.channels["Odor"][delivery_port[i]], + odor_duration, + dutycycle[i], + ) + + def opto_stim(self, duration, dutycycle): + self.thread.submit(self._pwd_out, self.channels["Opto"], duration, dutycycle) + + def sync_out(self, state=True): + self.GPIO.output(self.channels["Sync"]["out"], state) + + def give_sound(self, sound_freq=40500, volume=100, pulse_freq=0): + self.thread.submit( + self.__pulse_out, self.channels["Sound"][1], sound_freq, volume, pulse_freq + ) + + def stop_sound(self): + # Signal the sound generation thread to stop + self._sound_stop_event.set() + # Stop PWM if it exists + if self._sound_pwm: + self._sound_pwm.stop() + self._sound_pwm = None + # Ensure the sound pin is off + if "Sound" in self.channels: + self.GPIO.output(self.channels["Sound"][1], self.GPIO.LOW) + + def setup_touch_exit(self): + pass + + def set_operation_status(self, operation_status): + if self.exp.sync: + while not self.is_recording(): + log.info("Waiting for recording to start...") + sleep(1) + self.GPIO.output(self.channels["Status"], operation_status) + + def is_recording(self): + if self.exp.sync: + return self.GPIO.input(self.channels["Sync"]["rec"]) + else: + return False + + def cleanup(self): + self.set_operation_status(False) + self._sound_stop_event.set() + + # Clean up sound PWM + if self._sound_pwm: + self._sound_pwm.stop() + self._sound_pwm = None + + if self.callbacks: + if "Lick" in self.channels: + for channel in self.channels["Lick"]: + self.GPIO.remove_event_detect(self.channels["Lick"][channel]) + if "Proximity" in self.channels: + for channel in self.channels["Proximity"]: + self.GPIO.remove_event_detect(self.channels["Proximity"][channel]) + + self.GPIO.cleanup() + + if self.exp.sync: + if "Sync" in self.channels: + for channel in self.channels["Sync"]: + self.GPIO.remove_event_detect(self.channels["Sync"][channel]) + self.closeDatasets() + + def in_position(self, port=0): + """Determine if the specified port is in position and return the position data. + + Args: + port (int, optional): The port to check the position of. Defaults to 0. + + Returns: + tuple: A tuple containing the position data for the specified port in the following format: + - position (Port): A Port object representing the position of the specified port. + - position_dur (float): The duration in ms that the specified port has been in its current position. + - position_tmst (float): The timestamp in ms that the specified port activated. + + If the specified port is not in position, the tuple will be (0, 0, 0). + + """ + # Get the current position and the position of the specified port. + position = self.position + port = self._get_position(port) + + # # If neither position has been set, return (0, 0, 0). + if not position.port and not port: + return 0, 0, 0 + + # # If the specified port is not in the correct position, update the position and timestamp. + if position != Port(type="Proximity", port=port): + self._position_change(self.channels["Proximity"][max(port, position.port)]) + + # Calculate the duration and timestamp for the current position. + position_dur = ( + self.timer_ready.elapsed_time() if self.position.port else self.position_dur + ) + return self.position, position_dur, self.position_tmst + + def off_proximity(self): + """checks if any proximity ports is activated + + used to make sure that none of the ports is activated before move on to the next trial + if get_position returns 0 but position.type == Proximity means that self.position should + be off so call _position_change to reset it to the correct value + + Returns: + bool: True if all proximity ports are not activated + """ + port = self._get_position() + # port==0 means that no proximity port is activated + if port == 0: + # if self.position.type == 'Proximity' and port=0 + # add_event_detect has lost the off of the proximity + pos = self.position + if pos.type == "Proximity": + # call position_change to reset the self.position + self._position_change(self.channels["Proximity"][pos.port]) + return True + else: + return False + + def _get_position(self, ports=0): + """get the position of the proximity ports + + _extended_summary_ + + Args: + ports (int, optional): The port to check the position of. Defaults is 0 which means check all the ports. + + Returns: + int: the id of the activated port else 0 + """ + # if port is not specified check all proximity ports + if not ports: + ports = self.proximity_ports + elif not type(ports) is list: + ports = [ports] + for port in ports: + # find the position of the port + in_position = self.GPIO.input(self.channels["Proximity"][port]) + # if port invert take the opposite + if self.ports[Port(type="Proximity", port=port) == self.ports][0].invert: + in_position = not in_position + # return the port id if any port is in position + if in_position: + return port + return 0 + + def _position_change(self, channel=0): + """Update the position of the animal and log the in_position event. + + Position_change is called in as callback at event_detect of GPIO.BOTH of the proximity channels. + Also called from function in_position in the case where the callback has not run but the position has changed. + We want to log the port change and update the self.position with the activated port or reset it. + Also we calculate + - position_dur (float): The duration in ms that the specified port has been in its current position. + - position_tmst (float): The timestamp in ms that the specified port activated. + Before we log the position we check that it has been changed, because due to the small bouncetime + most proximity sensors(switches) will flicker back and forth between the two values before settling down. + + Args: + channel (int, optional): The channel number of the proximity sensor. Defaults to 0. + """ + # Get the port number corresponding to the proximity sensor channel + port = self._channel2port(channel, "Proximity") + # Check if the animal is in position + in_position = self._get_position(port.port) + # Start the timer if the animal is in position + if in_position: + self.timer_ready.start() + # Log the in_position event and update the position if there is a change in position + if in_position and not self.position.port: + self.position_tmst = self.beh.log_activity( + {**port.__dict__, "in_position": 1} + ) + self.position = port + elif not in_position and self.position.port: + tmst = self.beh.log_activity({**port.__dict__, "in_position": 0}) + self.position_dur = tmst - self.position_tmst + self.position = Port() + + def _give_pulse(self, port, duration): + """Generate liquid reward pulse using lgpio.tx_pulse for precise timing.""" + pin = self.channels["Liquid"][port] + + # Convert milliseconds to microseconds + pulse_us = int(duration * 1000) + + # Generate a single hardware-timed pulse + # tx_pulse(chip, gpio, pulse_on_us, pulse_off_us, offset_us, cycles) + lgpio.tx_pulse(self.chip, pin, pulse_us, 0, 0, 1) + + def _lick_port_activated(self, channel): + self.resp_tmst = self.logger.logger_timer.elapsed_time() + self.response = self._channel2port(channel, "Lick") + self.beh.log_activity({**self.response.__dict__, "time": self.resp_tmst}) + return self.response, self.resp_tmst + + def _sync_in(self, channel): + self.dataset.append("sync_data", [self.logger.logger_timer.elapsed_time()]) + + def _pwd_out(self, channel, duration, dutycycle): + """PWM output using RPi.GPIO (now rpi-lgpio).""" + pwm = self.GPIO.PWM(channel, self.frequency) + pwm.ChangeFrequency(self.frequency) + pwm.start(dutycycle) + sleep(duration / 1000) # to add a delay in seconds + pwm.stop() + + def __pulse_out(self, channel, freq, dutycycle=100, pulse_freq=0): + """Generate sound using RPi.GPIO.PWM instead of pigpio wave generation.""" + self._sound_stop_event.clear() + + if dutycycle == 0: + return + + # Speaker has non-monotonic response with ~50% duty cycle is maximum response + # Normalize percentage by /2 for speaker response + normalized_duty = dutycycle / 2 + + try: + # Create PWM instance + self._sound_pwm = self.GPIO.PWM(channel, freq) + + if pulse_freq == 0: + # Continuous tone + self._sound_pwm.start(normalized_duty) + # Keep running until stop is requested + while not self._sound_stop_event.is_set(): + sleep(0.01) # Small sleep to prevent CPU spinning + else: + # Pulsed tone - alternate between sound and silence + pulse_period = 1.0 / (pulse_freq * 2) # Time for one pulse (on + off) + + while not self._sound_stop_event.is_set(): + # Turn on sound + self._sound_pwm.start(normalized_duty) + if self._sound_stop_event.wait(timeout=pulse_period): + break + + # Turn off sound (silence period) + self._sound_pwm.stop() + if self._sound_stop_event.wait(timeout=pulse_period): + break + + except Exception as e: + log.error(f"Error in sound generation: {e}") + finally: + # Clean up PWM + if self._sound_pwm: + self._sound_pwm.stop() + self._sound_pwm = None diff --git a/src/ethopy/utils/start.py b/src/ethopy/utils/start.py index 7efe62a..c5fa179 100644 --- a/src/ethopy/utils/start.py +++ b/src/ethopy/utils/start.py @@ -2,37 +2,81 @@ import os from typing import Union +import logging import pygame import pygame_menu +log = logging.getLogger(__name__) + + +def get_resolution_framebuffer(): + try: + with open("/sys/class/graphics/fb0/virtual_size", "r") as f: + dimensions = f.read().strip().split(",") + return int(dimensions[0]), int(dimensions[1]) + except Exception as e: + log.error(f"Error reading framebuffer resolution: {e}") + return None + class PyWelcome: def __init__(self, logger) -> None: self.logger = logger - self.SCREEN_WIDTH = 800 - self.SCREEN_HEIGHT = 480 + # Initialize pygame first if not pygame.get_init(): pygame.init() - if self.logger.is_pi: - self.screen = pygame.display.set_mode( - (self.SCREEN_WIDTH, self.SCREEN_HEIGHT), pygame.FULLSCREEN - ) - else: + # AUTO-DETECT SCREEN RESOLUTION instead of hardcoding + # info = pygame.display.Info() + self.SCREEN_WIDTH = 1280 + self.SCREEN_HEIGHT = 720 + log.debug( + f"Detected screen resolution: {self.SCREEN_WIDTH}x{self.SCREEN_HEIGHT}" + ) + + # Set display mode - always try fullscreen first on Pi + try: + if self.logger.is_pi: + self.SCREEN_HEIGHT, self.SCREEN_WIDTH = get_resolution_framebuffer() + # Try fullscreen mode + self.screen = pygame.display.set_mode( + (self.SCREEN_WIDTH, self.SCREEN_HEIGHT), + pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE, + ) + log.debug("Fullscreen mode activated") + else: + # Windowed mode for testing + self.screen = pygame.display.set_mode( + (self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + ) + print("Windowed mode activated") + except pygame.error as e: + print(f"Failed to set fullscreen mode: {e}") + # Fallback to windowed mode self.screen = pygame.display.set_mode( (self.SCREEN_WIDTH, self.SCREEN_HEIGHT) ) + print("Fallback to windowed mode") - # Configure self.theme + # pygame.display.set_caption("EthoPy") + + # Hide mouse cursor using multiple methods + # self.hide_cursor_comprehensive() + + # Configure self.theme - adjust font sizes based on screen size self.theme = pygame_menu.themes.THEME_DARK.copy() self.theme.background_color = (0, 0, 0) self.theme.title_background_color = (43, 43, 43) - self.theme.title_font_size = 35 + + # Scale font sizes based on screen resolution + font_scale = min(self.SCREEN_WIDTH / 800, self.SCREEN_HEIGHT / 480) + self.theme.title_font_size = int(35 * font_scale) + self.theme.widget_font_size = int(30 * font_scale) + self.theme.widget_alignment = pygame_menu.locals.ALIGN_CENTER self.theme.widget_font_color = (255, 255, 255) - self.theme.widget_font_size = 30 self.theme.widget_padding = 0 # variables @@ -51,6 +95,8 @@ def setup_menus(self) -> None: def mainloop(self) -> None: """App mainloop.""" + clock = pygame.time.Clock() # Add clock for better performance + while ( self.logger.setup_status != "running" and self.logger.setup_status != "exit" ): @@ -58,18 +104,26 @@ def mainloop(self) -> None: for event in events: if event.type == pygame.QUIT: break + # Add escape key to exit fullscreen + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self.logger.update_setup_info({"status": "exit"}) + break if self.main_menu.is_enabled(): self.main_menu.update(events) + + # Clear screen before drawing + self.screen.fill((0, 0, 0)) self.main_menu.draw(self.screen) - pygame.display.update() - pygame.time.wait(2) + pygame.display.flip() # Use flip() instead of update() for better performance + clock.tick(60) # Limit to 60 FPS pygame_menu.events.CLOSE if pygame.get_init(): self.main_menu.disable() - pygame.mouse.set_visible(1) + pygame.mouse.set_visible(True) pygame.display.quit() def create_main(self) -> "pygame_menu.Menu": @@ -84,11 +138,15 @@ def create_main(self) -> "pygame_menu.Menu": theme=self.theme, ) + # Scale positions based on screen size + scale_x = self.SCREEN_WIDTH / 800 + scale_y = self.SCREEN_HEIGHT / 480 + menu.add.label( f"ip: {self.logger.get_ip()}, setup: {self.logger.setup}", - font_size=15, + font_size=int(15 * min(scale_x, scale_y)), align=pygame_menu.locals.ALIGN_LEFT, - ).translate(5, 10) + ).translate(int(5 * scale_x), int(10 * scale_y)) menu.add.button( f"Animal id: {self.animal_id}", @@ -97,7 +155,7 @@ def create_main(self) -> "pygame_menu.Menu": float=True, padding=(5, 10, 5, 10), background_color=(0, 15, 15), - ).translate(300, 40) + ).translate(int(300 * scale_x), int(40 * scale_y)) menu.add.button( f"Task id: {self.task_id}", @@ -106,7 +164,7 @@ def create_main(self) -> "pygame_menu.Menu": float=True, padding=(5, 10, 5, 10), background_color=(0, 15, 15), - ).translate(300, 130) + ).translate(int(300 * scale_x), int(130 * scale_y)) menu.add.button( "Start experiment", @@ -115,8 +173,8 @@ def create_main(self) -> "pygame_menu.Menu": float=True, padding=(10, 15, 10, 15), background_color=(0, 153, 0), - font_size=35, - ).translate(250, 260) + font_size=int(35 * min(scale_x, scale_y)), + ).translate(int(250 * scale_x), int(260 * scale_y)) menu.add.button( "Restart", @@ -125,7 +183,7 @@ def create_main(self) -> "pygame_menu.Menu": float=True, padding=(5, 23, 5, 30), background_color=(128, 128, 128), - ).translate(630, 290) + ).translate(int(630 * scale_x), int(290 * scale_y)) menu.add.button( "Power off", @@ -134,7 +192,7 @@ def create_main(self) -> "pygame_menu.Menu": float=True, background_color=(255, 0, 0), padding=(5, 10, 5, 10), - ).translate(630, 350) + ).translate(int(630 * scale_x), int(350 * scale_y)) return menu @@ -149,7 +207,7 @@ def create_animal(self): ) menu_animal.add.label( "Select Animal id: ", - font_size=20, + font_size=int(20 * min(self.SCREEN_WIDTH / 800, self.SCREEN_HEIGHT / 480)), ) self.curr_animal = "" menu_animal.add.vertical_margin(5) @@ -178,7 +236,7 @@ def create_task(self): ) menu_task.add.label( "Select Task id: ", - font_size=20, + font_size=int(20 * min(self.SCREEN_WIDTH / 800, self.SCREEN_HEIGHT / 480)), ) self.curr_task = "" menu_task.add.vertical_margin(5) @@ -204,7 +262,7 @@ def start_experiment(self): self.logger.update_setup_info({"status": "running"}) def close(self): - pygame.mouse.set_visible(1) + pygame.mouse.set_visible(True) pygame.quit() def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": @@ -214,8 +272,13 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": cursor = pygame_menu.locals.CURSOR_HAND + # Scale button sizes based on screen resolution + button_width = int(74 * min(self.SCREEN_WIDTH / 800, self.SCREEN_HEIGHT / 480)) + button_height = int(54 * min(self.SCREEN_WIDTH / 800, self.SCREEN_HEIGHT / 480)) + frame_width = int(299 * (self.SCREEN_WIDTH / 800)) + # Add horizontal frames - f1 = menu.add.frame_h(299, 54, margin=(5, 0)) + f1 = menu.add.frame_h(frame_width, button_height, margin=(5, 0)) b1 = f1.pack( menu.add.button("1", lambda: self._press(1, screen), cursor=cursor) ) @@ -229,7 +292,7 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": ) menu.add.vertical_margin(5) - f2 = menu.add.frame_h(299, 54, margin=(5, 0)) + f2 = menu.add.frame_h(frame_width, button_height, margin=(5, 0)) b4 = f2.pack( menu.add.button("4", lambda: self._press(4, screen), cursor=cursor) ) @@ -243,7 +306,7 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": ) menu.add.vertical_margin(5) - f3 = menu.add.frame_h(299, 54, margin=(5, 0)) + f3 = menu.add.frame_h(frame_width, button_height, margin=(5, 0)) b7 = f3.pack( menu.add.button("7", lambda: self._press(7, screen), cursor=cursor) ) @@ -257,7 +320,7 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": ) menu.add.vertical_margin(5) - f4 = menu.add.frame_h(299, 54, margin=(5, 0)) + f4 = menu.add.frame_h(frame_width, button_height, margin=(5, 0)) delete = f4.pack( menu.add.button("<", lambda: self._press("<", screen), cursor=cursor), align=pygame_menu.locals.ALIGN_RIGHT, @@ -272,7 +335,7 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": ) menu.add.vertical_margin(5) - f5 = menu.add.frame_h(299, 54, margin=(5, 0)) + f5 = menu.add.frame_h(frame_width, button_height, margin=(5, 0)) ok = f5.pack( menu.add.button( "Ok", lambda: self._press("ok", screen, log_function), cursor=cursor @@ -280,15 +343,42 @@ def add_num_pad(self, menu, log_function, screen) -> "pygame_menu.Menu": align=pygame_menu.locals.ALIGN_CENTER, ) - # Add decorator for each object + # Add decorator for each object - scale rectangle sizes + rect_width = int(button_width) + rect_height = int(button_height) + for widget in (b1, b2, b3, b4, b5, b6, b7, b8, b9, b0, ok, delete, dot): w_deco = widget.get_decorator() if widget != ok: - w_deco.add_rectangle(-37, -27, 74, 54, (15, 15, 15)) - on_layer = w_deco.add_rectangle(-37, -27, 74, 54, (84, 84, 84)) + w_deco.add_rectangle( + -rect_width // 2, + -rect_height // 2, + rect_width, + rect_height, + (15, 15, 15), + ) + on_layer = w_deco.add_rectangle( + -rect_width // 2, + -rect_height // 2, + rect_width, + rect_height, + (84, 84, 84), + ) else: - w_deco.add_rectangle(-37, -27, 74, 54, (38, 96, 103)) - on_layer = w_deco.add_rectangle(-37, -27, 74, 54, (40, 171, 187)) + w_deco.add_rectangle( + -rect_width // 2, + -rect_height // 2, + rect_width, + rect_height, + (38, 96, 103), + ) + on_layer = w_deco.add_rectangle( + -rect_width // 2, + -rect_height // 2, + rect_width, + rect_height, + (40, 171, 187), + ) w_deco.disable(on_layer) widget.set_attribute("on_layer", on_layer) From 8695dd3d2b65992ea0f7e71c2644dd057f1dc485 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Fri, 31 Oct 2025 13:05:01 +0200 Subject: [PATCH 02/10] Merge branch 'main' of https://github.com/ef-lab/ethopy_package into rp5 From f207ec5ec20c3fea3548868514db42bd1eac8558 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 8 Sep 2026 11:52:47 +0300 Subject: [PATCH 03/10] Update calibrate.py scales the numpad button padding, which the branch had left at fixed 800x480 values while scaling the rectangles drawn behind them. --- src/ethopy/experiments/calibrate.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ethopy/experiments/calibrate.py b/src/ethopy/experiments/calibrate.py index ca16eb4..4cecf93 100644 --- a/src/ethopy/experiments/calibrate.py +++ b/src/ethopy/experiments/calibrate.py @@ -577,7 +577,14 @@ def widget_select(sel: bool, wid: "pygame_menu.widgets.Widget", _): wid.get_decorator().disable(lay) widget.set_onselect(widget_select) - widget.set_padding((2, 19, 0, 23)) + widget.set_padding( + ( + int(2 * self.display_scale), + int(19 * self.display_scale), + 0, + int(23 * self.display_scale), + ) + ) def _press(self, digit, _func=None) -> None: """Press numpad digit""" From 1744452393de4c6a4a906f16eb0a49a8b637a186 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 8 Sep 2026 11:53:26 +0300 Subject: [PATCH 04/10] Update start.py unpacks width and height in the right order, and skips the assignment when the framebuffer read fails. Previously a failed read returned None and the unpack raised TypeError, which the surrounding except pygame.error would not have caught. --- src/ethopy/utils/start.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ethopy/utils/start.py b/src/ethopy/utils/start.py index e42443a..d961b18 100644 --- a/src/ethopy/utils/start.py +++ b/src/ethopy/utils/start.py @@ -39,7 +39,9 @@ def __init__(self, logger) -> None: # Set display mode - always try fullscreen first on Pi try: if self.logger.is_pi: - self.SCREEN_HEIGHT, self.SCREEN_WIDTH = get_resolution_framebuffer() + resolution = get_resolution_framebuffer() + if resolution is not None: + self.SCREEN_WIDTH, self.SCREEN_HEIGHT = resolution # Try fullscreen mode self.screen = pygame.display.set_mode( (self.SCREEN_WIDTH, self.SCREEN_HEIGHT), From 2ebb22ed0dd5de98e8a32b78b636b02846d3ecec Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 8 Sep 2026 17:13:21 +0300 Subject: [PATCH 05/10] Update RPPorts5.py --- src/ethopy/interfaces/RPPorts5.py | 50 ++++++++----------------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/src/ethopy/interfaces/RPPorts5.py b/src/ethopy/interfaces/RPPorts5.py index f3775bc..8843d14 100644 --- a/src/ethopy/interfaces/RPPorts5.py +++ b/src/ethopy/interfaces/RPPorts5.py @@ -226,46 +226,23 @@ def in_position(self, port=0): If the specified port is not in position, the tuple will be (0, 0, 0). """ - # Get the current position and the position of the specified port. - position = self.position - port = self._get_position(port) - - # # If neither position has been set, return (0, 0, 0). - if not position.port and not port: + # self.position is maintained by the debounced proximity callbacks + if not self.position.port or (port and self.position.port != port): return 0, 0, 0 - - # # If the specified port is not in the correct position, update the position and timestamp. - if position != Port(type="Proximity", port=port): - self._position_change(self.channels["Proximity"][max(port, position.port)]) - - # Calculate the duration and timestamp for the current position. - position_dur = ( - self.timer_ready.elapsed_time() if self.position.port else self.position_dur - ) - return self.position, position_dur, self.position_tmst + return self.position, self.timer_ready.elapsed_time(), self.position_tmst def off_proximity(self): - """checks if any proximity ports is activated + """Check that no proximity port is activated. - used to make sure that none of the ports is activated before move on to the next trial - if get_position returns 0 but position.type == Proximity means that self.position should - be off so call _position_change to reset it to the correct value + Used to make sure that none of the ports is activated before moving on to + the next trial. The answer comes from self.position, which the debounced + GPIO callbacks maintain, so the trial logic and the logged activity always + agree on where the animal is. Returns: bool: True if all proximity ports are not activated """ - port = self._get_position() - # port==0 means that no proximity port is activated - if port == 0: - # if self.position.type == 'Proximity' and port=0 - # add_event_detect has lost the off of the proximity - pos = self.position - if pos.type == "Proximity": - # call position_change to reset the self.position - self._position_change(self.channels["Proximity"][pos.port]) - return True - else: - return False + return not self.position.port def _get_position(self, ports=0): """get the position of the proximity ports @@ -313,17 +290,16 @@ def _position_change(self, channel=0): port = self._channel2port(channel, "Proximity") # Check if the animal is in position in_position = self._get_position(port.port) - # Start the timer if the animal is in position + # Log the in_position event and update the position if there is a change in position + # The ready timer starts on a real entry, not on every edge if in_position: self.timer_ready.start() # Log the in_position event and update the position if there is a change in position if in_position and not self.position.port: - self.position_tmst = self.beh.log_activity( - {**port.__dict__, "in_position": 1} - ) + self.position_tmst = self.beh.log_activity({**port.__dict__, 'in_position': 1}) self.position = port elif not in_position and self.position.port: - tmst = self.beh.log_activity({**port.__dict__, "in_position": 0}) + tmst = self.beh.log_activity({**port.__dict__, 'in_position': 0}) self.position_dur = tmst - self.position_tmst self.position = Port() From d91347d67a2369666ede52b07ca5e92b50fc8aef Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Tue, 8 Sep 2026 17:19:29 +0300 Subject: [PATCH 06/10] Update start.py --- src/ethopy/utils/start.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/ethopy/utils/start.py b/src/ethopy/utils/start.py index d961b18..0912b8b 100644 --- a/src/ethopy/utils/start.py +++ b/src/ethopy/utils/start.py @@ -10,16 +10,6 @@ log = logging.getLogger(__name__) -def get_resolution_framebuffer(): - try: - with open("/sys/class/graphics/fb0/virtual_size", "r") as f: - dimensions = f.read().strip().split(",") - return int(dimensions[0]), int(dimensions[1]) - except Exception as e: - log.error(f"Error reading framebuffer resolution: {e}") - return None - - class PyWelcome: def __init__(self, logger) -> None: self.logger = logger @@ -39,12 +29,12 @@ def __init__(self, logger) -> None: # Set display mode - always try fullscreen first on Pi try: if self.logger.is_pi: - resolution = get_resolution_framebuffer() - if resolution is not None: - self.SCREEN_WIDTH, self.SCREEN_HEIGHT = resolution - # Try fullscreen mode + # Ask SDL for the desktop resolution. Requesting a size that + # differs from it returns a surface that segfaults on draw, and + # /sys/class/graphics/fb0/virtual_size reports its dimensions + # in the opposite order on some Pis. self.screen = pygame.display.set_mode( - (self.SCREEN_WIDTH, self.SCREEN_HEIGHT), + (0, 0), pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE, ) log.debug("Fullscreen mode activated") @@ -62,6 +52,10 @@ def __init__(self, logger) -> None: ) print("Fallback to windowed mode") + # Every menu is sized from the surface pygame actually gave us. + self.SCREEN_WIDTH, self.SCREEN_HEIGHT = self.screen.get_size() + log.debug(f"Display surface size: {self.SCREEN_WIDTH}x{self.SCREEN_HEIGHT}") + # pygame.display.set_caption("EthoPy") # Hide mouse cursor using multiple methods From c6f1f4cbdd5aa10e14e6e7d7f5e4e4ad2ea2f63b Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 9 Sep 2026 12:52:15 +0300 Subject: [PATCH 07/10] Update local_conf.md --- docs/local_conf.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/local_conf.md b/docs/local_conf.md index bf2c1d3..f6e6344 100644 --- a/docs/local_conf.md +++ b/docs/local_conf.md @@ -30,6 +30,8 @@ With no other instruction, EthoPy loads: The file is read once, at `import ethopy`. If it does not exist, EthoPy runs entirely on its [built-in defaults](#built-in-defaults). +**Editing the file has no effect on a running EthoPy.** The configuration is read once, at import, and kept in memory for the lifetime of the process. Restart EthoPy for a change to take effect. + ### 2. A different config file on the command line Every EthoPy run can point at another file with `-c` / `--config`: From 5972cbf2da0a98f19ae01885f7fee261d44c02c2 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Fri, 11 Sep 2026 11:38:59 +0300 Subject: [PATCH 08/10] fix: reset timer only in real entry The ready timer was restarted on every proximity edge while the animal stayed in position, so the dwell time it measured was reset by switch bounce. It now starts only on a real entry, when the stored position was previously empty. The same fix is applied to both interfaces so their behaviour stays identical. --- src/ethopy/interfaces/RPPorts.py | 5 ++--- src/ethopy/interfaces/RPPorts5.py | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/ethopy/interfaces/RPPorts.py b/src/ethopy/interfaces/RPPorts.py index 1e167d8..7da357e 100644 --- a/src/ethopy/interfaces/RPPorts.py +++ b/src/ethopy/interfaces/RPPorts.py @@ -248,11 +248,10 @@ def _position_change(self, channel=0): port = self._channel2port(channel, 'Proximity') # Check if the animal is in position in_position = self._get_position(port.port) - # Start the timer if the animal is in position - if in_position: - self.timer_ready.start() # Log the in_position event and update the position if there is a change in position if in_position and not self.position.port: + # Start the ready timer on a real entry, not on every edge + self.timer_ready.start() self.position_tmst = self.beh.log_activity({**port.__dict__, 'in_position': 1}) self.position = port elif not in_position and self.position.port: diff --git a/src/ethopy/interfaces/RPPorts5.py b/src/ethopy/interfaces/RPPorts5.py index 8843d14..a7f735a 100644 --- a/src/ethopy/interfaces/RPPorts5.py +++ b/src/ethopy/interfaces/RPPorts5.py @@ -291,11 +291,9 @@ def _position_change(self, channel=0): # Check if the animal is in position in_position = self._get_position(port.port) # Log the in_position event and update the position if there is a change in position - # The ready timer starts on a real entry, not on every edge - if in_position: - self.timer_ready.start() - # Log the in_position event and update the position if there is a change in position if in_position and not self.position.port: + # The ready timer starts on a real entry, not on every edge + self.timer_ready.start() self.position_tmst = self.beh.log_activity({**port.__dict__, 'in_position': 1}) self.position = port elif not in_position and self.position.port: From ebf80c8e52788c2ae812858747d39124d2b64a4f Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Fri, 11 Sep 2026 11:43:14 +0300 Subject: [PATCH 09/10] Update start.py --- src/ethopy/utils/start.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ethopy/utils/start.py b/src/ethopy/utils/start.py index 0912b8b..1909e8a 100644 --- a/src/ethopy/utils/start.py +++ b/src/ethopy/utils/start.py @@ -18,13 +18,8 @@ def __init__(self, logger) -> None: if not pygame.get_init(): pygame.init() - # AUTO-DETECT SCREEN RESOLUTION instead of hardcoding - # info = pygame.display.Info() - self.SCREEN_WIDTH = 1280 - self.SCREEN_HEIGHT = 720 - log.debug( - f"Detected screen resolution: {self.SCREEN_WIDTH}x{self.SCREEN_HEIGHT}" - ) + self.SCREEN_WIDTH = 800 + self.SCREEN_HEIGHT = 480 # Set display mode - always try fullscreen first on Pi try: From 1a4e36463b9623e3ed6b4f36e718e0af0f41d362 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Fri, 11 Sep 2026 11:49:32 +0300 Subject: [PATCH 10/10] Similar to start.py set fullscreen and get the resolution after --- src/ethopy/experiments/calibrate.py | 46 +++++++++-------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/src/ethopy/experiments/calibrate.py b/src/ethopy/experiments/calibrate.py index 4cecf93..def7f60 100644 --- a/src/ethopy/experiments/calibrate.py +++ b/src/ethopy/experiments/calibrate.py @@ -104,56 +104,40 @@ def _init_pygame(self): """Initialize pygame with Pi 5 compatibility""" if not pygame.get_init(): pygame.init() - - # Auto-detect screen resolution for Pi 5 - try: - info = pygame.display.Info() - detected_width = info.current_w - detected_height = info.current_h - - log.info(f"Detected screen resolution: {detected_width}x{detected_height}") - - # Use detected resolution if reasonable, otherwise fallback - if detected_width > 400 and detected_height > 300: - self.screen_width = detected_width - self.screen_height = detected_height - else: - log.warning("Using fallback resolution 800x480") - - except Exception as e: - log.warning(f"Could not detect screen resolution: {e}") - # Calculate scaling factor for UI elements - self.display_scale = min(self.screen_width / 800, self.screen_height / 480) - # Set display mode with Pi 5 compatibility try: if self.logger.is_pi: - # Try fullscreen mode first + # Ask SDL for the desktop resolution. Requesting a size that + # differs from it returns a surface that segfaults on draw, and + # /sys/class/graphics/fb0/virtual_size reports its dimensions + # in the opposite order on some Pis. self.screen = pygame.display.set_mode( - (self.screen_width, self.screen_height), + (0, 0), pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE ) self.is_fullscreen = True log.info("Fullscreen mode activated") - + # Hide mouse cursor for kiosk mode pygame.mouse.set_visible(False) - + else: # Windowed mode for development self.screen = pygame.display.set_mode((self.screen_width, self.screen_height)) log.info("Windowed mode activated") - + except pygame.error as e: log.error(f"Failed to set display mode: {e}") # Fallback to windowed mode self.screen = pygame.display.set_mode((800, 480)) - self.screen_width = 800 - self.screen_height = 480 - self.display_scale = 1.0 log.info("Fallback to 800x480 windowed mode") + # Every widget is sized from the surface pygame actually gave us. + self.screen_width, self.screen_height = self.screen.get_size() + self.display_scale = min(self.screen_width / 800, self.screen_height / 480) + log.info(f"Display surface size: {self.screen_width}x{self.screen_height}") + pygame.display.set_caption("EthoPy Calibration") def _setup_theme(self): @@ -232,9 +216,7 @@ def _toggle_fullscreen(self): self.is_fullscreen = False pygame.mouse.set_visible(True) else: - self.screen = pygame.display.set_mode( - (self.screen_width, self.screen_height), pygame.FULLSCREEN - ) + self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN) self.is_fullscreen = True pygame.mouse.set_visible(False) except Exception as e: