From 4b845fcf1808f4e800a6ce9a58ee52c1f4283bd7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 16 Dec 2025 13:35:30 +0000 Subject: [PATCH 1/5] Remove dependencies from camera --- .../things/camera/__init__.py | 73 ++++--------------- 1 file changed, 14 insertions(+), 59 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 32f3c889..6171f081 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -10,7 +10,6 @@ from __future__ import annotations import io import json -import logging import os import tempfile import time @@ -34,8 +33,6 @@ from openflexure_microscope_server.background_detect import ( ) from openflexure_microscope_server.ui import ActionButton, PropertyControl -LOGGER = logging.getLogger(__name__) - class JPEGBlob(lt.blob.Blob): """A class representing a JPEG image as a LabThings FastAPI Blob.""" @@ -282,8 +279,6 @@ class BaseCamera(lt.Thing): @lt.action def capture_jpeg( self, - metadata_getter: lt.deps.GetThingStates, - logger: lt.deps.InvocationLogger, stream_name: str = "main", wait: Optional[float] = None, ) -> JPEGBlob: @@ -292,9 +287,6 @@ class BaseCamera(lt.Thing): This will use the internal capture image functionally of capture_image of the specific camera being used. - :param metadata_getter: LabThings GetThingStates dependency, automatically - injected. - :param logger: LabThings InvocationLogger dependency, automatically injected. :param stream_name: A stream name supported by this camera. :param wait: (Optional, float) Set a timeout in seconds. If None it will use the default for the underlying camera. @@ -305,13 +297,12 @@ class BaseCamera(lt.Thing): img = self.capture_image(stream_name, wait) - capture_metadata = self._capture_metadata(metadata_getter()) + capture_metadata = self._capture_metadata() self._save_capture( jpeg_path=jpeg_path, image=img, metadata=capture_metadata, - logger=logger, ) return JPEGBlob.from_temporary_directory(directory, fname) @@ -390,70 +381,43 @@ class BaseCamera(lt.Thing): def capture_and_save( self, jpeg_path: str, - logger: lt.deps.InvocationLogger, - metadata_getter: lt.deps.GetThingStates, save_resolution: Optional[Tuple[int, int]] = None, ) -> None: """Capture an image and save it to disk. :param jpeg_path: The path to save the file to - :param logger: This should be injected automatically by Labthings FastAPI - when calling the action - :param metadata_getter: This should be injected automatically by Labthings - FastAPI when calling the action :param save_resolution: can be set to resize the image before saving. By default this is None meaning that the image is saved at original resolution. """ - image, capture_metadata = self._robust_image_capture( - metadata_getter, - logger=logger, - ) + image, capture_metadata = self._robust_image_capture() - self._save_capture( - jpeg_path, - image, - capture_metadata, - logger, - save_resolution, - ) + self._save_capture(jpeg_path, image, capture_metadata, save_resolution) @lt.action - def capture_to_memory( - self, - logger: lt.deps.InvocationLogger, - metadata_getter: lt.deps.GetThingStates, - buffer_max: int = 1, - ) -> int: + def capture_to_memory(self, buffer_max: int = 1) -> int: """Capture an image to memory. This can be saved later with ``save_from_memory``. Note that only one image is held in memory so this will overwrite any image in memory. - :param logger: This should be injected automatically by Labthings FastAPI - when calling the action - :param metadata_getter: This should be injected automatically by Labthings - FastAPI when calling the action :param buffer_max: The maximum number of images that should be in the buffer once this images is added. Default is 1. :returns: the buffer id of the image captured """ - image, metadata = self._robust_image_capture(metadata_getter, logger) + image, metadata = self._robust_image_capture() return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) @lt.action def save_from_memory( self, jpeg_path: str, - logger: lt.deps.InvocationLogger, save_resolution: Optional[Tuple[int, int]] = None, buffer_id: Optional[int] = None, ) -> None: """Save an image that has been captured to memory. :param jpeg_path: The path to save the file to - :param logger: This should be injected automatically by Labthings FastAPI - when calling the action :param save_resolution: can be set to resize the image before saving. By default this is None meaning that the image is saved at original resolution. @@ -466,7 +430,6 @@ class BaseCamera(lt.Thing): jpeg_path=jpeg_path, image=image, metadata=metadata, - logger=logger, save_resolution=save_resolution, ) @@ -475,11 +438,7 @@ class BaseCamera(lt.Thing): """Clear all images in memory.""" self._memory_buffer.clear() - def _robust_image_capture( - self, - metadata_getter: lt.deps.GetThingStates, - logger: lt.deps.InvocationLogger, - ) -> Tuple[Image, Mapping[str, Any]]: + def _robust_image_capture(self) -> Tuple[Image, Mapping[str, Any]]: """Capture an image in memory and return it with metadata. This robust capturing method attempts to capture the image five times @@ -491,22 +450,19 @@ class BaseCamera(lt.Thing): """ for capture_attempts in range(5): try: - metadata = metadata_getter() - capture_metadata = self._capture_metadata(metadata) + capture_metadata = self._capture_metadata() image = self.capture_image(stream_name="main", wait=5) return image, capture_metadata except TimeoutError: - logger.warning( + self.logger.warning( f"Attempt {capture_attempts + 1} to capture image timed out. Do you have enough RAM?" ) raise CaptureError("An error occurred while capturing after 5 attempts") - def _capture_metadata( - self, - metadata: Mapping[str, Any], - ) -> dict: + def _capture_metadata(self) -> dict: """Return the metadata for a capture, from the thing states, time and known names.""" + metadata = self._thing_server_interface.get_thing_states() current_time = datetime.now() return { "capture_time": current_time.timestamp(), @@ -566,12 +522,11 @@ class BaseCamera(lt.Thing): jpeg_path: str, image: Image, metadata: dict, - logger: lt.deps.InvocationLogger, save_resolution: Optional[Tuple[int, int]] = None, ) -> None: """Save the captured image and metadata to disk. - A warning (via InvocationLogger) is raised if metadata is failed to be added + A warning is logged if metadata cannot be added. :raises IOError: if the file cannot be saved @@ -593,7 +548,7 @@ class BaseCamera(lt.Thing): except Exception: # We need to capture any exception as there are many reasons metadata # might not be added. We warn rather than log the error. - logger.exception(f"Failed to add metadata to {jpeg_path}") + self.logger.exception(f"Failed to add metadata to {jpeg_path}") except Exception as e: raise IOError(f"An error occurred while saving {jpeg_path}") from e @@ -639,7 +594,7 @@ class BaseCamera(lt.Thing): def detector_name(self, name: str) -> None: """Validate and set detector_name.""" if name not in self.background_detectors: - LOGGER.warning(f"{name} is not a valid background detector name.") + self.logger.warning(f"{name} is not a valid background detector name.") self._detector_name = name @property @@ -697,7 +652,7 @@ class BaseCamera(lt.Thing): obj.settings = instance_data["settings"] obj.background_data = instance_data["background_data"] else: - LOGGER.warning( + self.logger.warning( f"No background detector named {name}, settings will be discarded." ) From 426178ba31b8d8628ab8ef5c80873ed2dbdf541e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 16 Dec 2025 13:45:51 +0000 Subject: [PATCH 2/5] Remove dependencies from stages --- .../things/stage/__init__.py | 30 ++++--------------- .../things/stage/dummy.py | 6 ++-- .../things/stage/sangaboard.py | 13 +++----- 3 files changed, 11 insertions(+), 38 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index d22809ce..86733f0a 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -133,24 +133,15 @@ class BaseStage(lt.Thing): self.axis_inverted = direction @lt.action - def move_relative( - self, - cancel: lt.deps.CancelHook, - block_cancellation: bool = False, - **kwargs: int, - ) -> None: + def move_relative(self, block_cancellation: bool = False, **kwargs: int) -> None: """Make a relative move. Keyword arguments should be axis names.""" self._hardware_move_relative( - cancel=cancel, block_cancellation=block_cancellation, **self._apply_axis_direction(kwargs), ) def _hardware_move_relative( - self, - cancel: lt.deps.CancelHook, - block_cancellation: bool = False, - **kwargs: int, + self, block_cancellation: bool = False, **kwargs: int ) -> Never: """Make a relative move in the coordinate system used by the physical hardware. @@ -161,22 +152,15 @@ class BaseStage(lt.Thing): ) @lt.action - def move_absolute( - self, - cancel: lt.deps.CancelHook, - block_cancellation: bool = False, - **kwargs: int, - ) -> None: + def move_absolute(self, block_cancellation: bool = False, **kwargs: int) -> None: """Make an absolute move. Keyword arguments should be axis names.""" self._hardware_move_absolute( - cancel=cancel, block_cancellation=block_cancellation, **self._apply_axis_direction(kwargs), ) def _hardware_move_absolute( self, - cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: int, ) -> Never: @@ -212,20 +196,16 @@ class BaseStage(lt.Thing): return (position_dict["x"], position_dict["y"], position_dict["z"]) @lt.action - def move_to_xyz_position( - self, cancel: lt.deps.CancelHook, xyz_pos: tuple[int, int, int] - ) -> None: + def move_to_xyz_position(self, xyz_pos: tuple[int, int, int]) -> None: """Move to the location specified by an (x, y, z) tuple. - :param cancel: A cancel hook for cancelling the move. This dependency should be - injected automatically by LabThings-FastAPI :param xyz_pos: The (x, y, z) position to move to. :raises KeyError: if this stage does not have axes named "x", "y", and "z". This method provides the interface expected by the camera_stage_mapping. """ - self.move_absolute(cancel=cancel, x=xyz_pos[0], y=xyz_pos[1], z=xyz_pos[2]) + self.move_absolute(x=xyz_pos[0], y=xyz_pos[1], z=xyz_pos[2]) StageDependency = lt.deps.direct_thing_client_dependency(BaseStage, "stage") diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 47eca648..3b8025ff 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -55,7 +55,6 @@ class DummyStage(BaseStage): def _hardware_move_relative( self, - cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: int, ) -> None: @@ -71,7 +70,7 @@ class DummyStage(BaseStage): if block_cancellation: time.sleep(dt) else: - cancel.sleep(dt) + lt.cancellable_sleep(dt) fraction_complete = (time.time() - start_time) / (dt * max_displacement) self.instantaneous_position = { ax: self._hardware_position[ax] + int(fraction_complete * disp) @@ -93,7 +92,6 @@ class DummyStage(BaseStage): def _hardware_move_absolute( self, - cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: int, ) -> None: @@ -104,7 +102,7 @@ class DummyStage(BaseStage): if axis in self.axis_names } self._hardware_move_relative( - cancel, block_cancellation=block_cancellation, **displacement + block_cancellation=block_cancellation, **displacement ) @lt.action diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 92ef8d5c..290be4bf 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import threading import time from collections.abc import Mapping @@ -18,8 +17,6 @@ import sangaboard from . import BaseStage -LOGGER = logging.getLogger(__name__) - REQUIRED_VERSION = semver.Version.parse("1.0.0") RECOMMENDED_VERSION = semver.Version.parse("1.0.4") @@ -117,14 +114,13 @@ class SangaboardThing(BaseStage): # Warn if version is below recommended if version < RECOMMENDED_VERSION: - LOGGER.warning( + self.logger.warning( f"Sangaboard firmware version {version} is below the recommended " f"{RECOMMENDED_VERSION}." ) def _hardware_move_relative( self, - cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: int, ) -> None: @@ -138,7 +134,7 @@ class SangaboardThing(BaseStage): sb.query("notify_on_stop") else: while sb.query("moving?") == "true": - cancel.sleep(0.1) + lt.cancellable_sleep(0.1) except lt.exceptions.InvocationCancelledError as e: # If the move has been cancelled, stop it but don't handle the exception. # We need the exception to propagate in order to stop any calling tasks, @@ -151,7 +147,6 @@ class SangaboardThing(BaseStage): def _hardware_move_absolute( self, - cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: int, ) -> None: @@ -164,7 +159,7 @@ class SangaboardThing(BaseStage): if axis in self.axis_names } self._hardware_move_relative( - cancel, block_cancellation=block_cancellation, **displacement + block_cancellation=block_cancellation, **displacement ) @lt.action @@ -203,7 +198,7 @@ class SangaboardThing(BaseStage): # cannot be used. intended_brightness = float(return_value[7:]) on_brightness = 0.32 - LOGGER.warning( + self.logger.warning( "Brightness control is not yet implemented. Desired brightness: " f"{intended_brightness}. Set brightness: {on_brightness}" ) From 3332bc5e1d4e0b7966476ca1c15ba487c1116cd7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 16 Dec 2025 14:05:18 +0000 Subject: [PATCH 3/5] Update tests for stage without deps --- tests/mock_things/mock_cancel.py | 19 ------------------- tests/test_stage.py | 26 ++++---------------------- 2 files changed, 4 insertions(+), 41 deletions(-) delete mode 100644 tests/mock_things/mock_cancel.py diff --git a/tests/mock_things/mock_cancel.py b/tests/mock_things/mock_cancel.py deleted file mode 100644 index 1e9960a7..00000000 --- a/tests/mock_things/mock_cancel.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Create a fake CancelHook used instead of a dependency, it cannot be used to cancel! - -Certain actions require a CancelHook to be supplied if called directly. The only method -we use from the LabThings CancelHook is ``sleep()``. The CanceHook ``sleep()`` works -exactly like ``time.sleep()`` except with raise an ``InvocationCancelledError`` if the -server receives a cancellation request. - -This is just an object with a sleep method. -""" - -import time - - -class MockCancel: - """A class to use when directly calling an action with CancelHook dependency.""" - - def sleep(self, time_in_seconds: float) -> None: - """Sleep for the input number of seconds.""" - time.sleep(time_in_seconds) diff --git a/tests/test_stage.py b/tests/test_stage.py index 2a28698c..22ec73dc 100644 --- a/tests/test_stage.py +++ b/tests/test_stage.py @@ -18,8 +18,6 @@ from openflexure_microscope_server.things.stage import ( ) from openflexure_microscope_server.things.stage.dummy import DummyStage -from .mock_things.mock_cancel import MockCancel - # Keep the size and number of moves fairly small or the tests can take forever point3d = st.tuples( st.integers(min_value=-100, max_value=100), @@ -60,12 +58,7 @@ def test_override_base_movement(): class BadStage1(BaseStage): @lt.action - def move_relative( - self, - cancel: lt.deps.CancelHook, - block_cancellation: bool = False, - **kwargs: int, - ): + def move_relative(self, block_cancellation: bool = False, **kwargs: int): pass with pytest.raises(RedefinedBaseMovementError): @@ -73,12 +66,7 @@ def test_override_base_movement(): class BadStage2(BaseStage): @lt.action - def move_absolute( - self, - cancel: lt.deps.CancelHook, - block_cancellation: bool = False, - **kwargs: int, - ): + def move_absolute(self, block_cancellation: bool = False, **kwargs: int): pass with pytest.raises(RedefinedBaseMovementError): @@ -200,7 +188,6 @@ def _test_move_relative(dummy_stage, axis_inverted, path): :param axis_inverted: Is used to set the inversion. :param path: The 3d path to move over, generated by hypothesis. """ - cancel = MockCancel() dummy_stage.axis_inverted = axis_inverted # Explicitly do axes calculation here to check logic in main code. x_dir = -1 if axis_inverted["x"] else 1 @@ -209,9 +196,7 @@ def _test_move_relative(dummy_stage, axis_inverted, path): position = list(dummy_stage.position.values()) for movement in path: - dummy_stage.move_relative( - cancel=cancel, x=movement[0], y=movement[1], z=movement[2] - ) + dummy_stage.move_relative(x=movement[0], y=movement[1], z=movement[2]) position = [pos + move for pos, move in zip(position, movement, strict=True)] stage_pos = dummy_stage.get_xyz_position() hw_pos = dummy_stage._hardware_position @@ -257,7 +242,6 @@ def _test_move_absolute(dummy_stage, axis_inverted, path): :param axis_inverted: Is used to set the inversion. :param path: The 3d path to move over, generated by hypothesis. """ - cancel = MockCancel() dummy_stage.axis_inverted = axis_inverted # Explicitly do axes calculation here to check logic in main code. x_dir = -1 if axis_inverted["x"] else 1 @@ -265,9 +249,7 @@ def _test_move_absolute(dummy_stage, axis_inverted, path): z_dir = -1 if axis_inverted["z"] else 1 position = list(dummy_stage.position.values()) for move_to in path: - dummy_stage.move_absolute( - cancel=cancel, x=move_to[0], y=move_to[1], z=move_to[2] - ) + dummy_stage.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2]) position = move_to stage_pos = dummy_stage.get_xyz_position() hw_pos = dummy_stage._hardware_position From 4a72521531a9e00318b1997c156ba9fc5e462a8e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 16 Dec 2025 14:07:40 +0000 Subject: [PATCH 4/5] Remove deps from system thing --- src/openflexure_microscope_server/things/system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/system.py b/src/openflexure_microscope_server/things/system.py index 2d5fa63a..7c72b2ab 100644 --- a/src/openflexure_microscope_server/things/system.py +++ b/src/openflexure_microscope_server/things/system.py @@ -121,9 +121,9 @@ class OpenFlexureSystem(lt.Thing): return CommandOutput(output=out, error=err) @lt.action - def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping: + def get_things_state(self) -> Mapping: """Metadata summarising the current state of all Things in the server.""" - return metadata_getter() + return self._thing_server_interface.get_thing_states() @property def thing_state(self) -> Mapping[str, Any]: From b22341f29c32af5e0f9dffa5e30a9fe9a07b3337 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 16 Dec 2025 14:43:45 +0000 Subject: [PATCH 5/5] Update picamera coverage zip --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 55d371724a48dd8ffa270a7ac31e8a48f5fab211..187414177c106fb662468766500a8624fa55c1cf 100644 GIT binary patch delta 297 zcmbQXjCtBJW}yIYW)=|!5O6D<5S>f&VA}VgB9xF?{#=7W0+xC2n>U z@Z_7^)Au_*mW7p(v(bu~>DPC$l*nl_7kWrCd|+c>U~qZBV6dYhoJGQbfvMqO0<&}j z?}4kAnHq8#Z?vG3xm`Y^E5Lv alO(0u0B=Sn5oT2HPwu^Bfw2ATB~Jj@>|YT8 delta 302 zcmbQXjCtBJW}yIYW)=|!5ZIG5A-XsGTFpiwl?I_02L7M?hxvE&hx6Uyo5z>W7qi(> zz=dz}#J=AtNi3|4oQ*EbOuydiYMkD5hR5|3LxVX31A_t+d&5E2JxT`}7zG%3*rabT z^e`T{x}25aHq!=vZjS^G1_lEr1~WDW1~-NeY=`DE%zb|BtO!fM0Y+(tWS(nTYZ)8< zt~B|CKg6XDaMAD$tI~rhG}VuX{lxghNkAp gsg@QdhDND|O0@yrj7%cTs9`X<_mTy|_Oq8f0aid{vj6}9