diff --git a/picamera_coverage.zip b/picamera_coverage.zip
index 777a12e1..e47b59ea 100644
Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ
diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py
index da852438..0505e254 100644
--- a/src/openflexure_microscope_server/scan_directories.py
+++ b/src/openflexure_microscope_server/scan_directories.py
@@ -8,7 +8,7 @@ import shutil
import threading
import zipfile
from datetime import datetime, timedelta
-from typing import Any, Mapping, Optional, Self
+from typing import Any, Literal, Mapping, Optional, Self
from pydantic import (
BaseModel,
@@ -55,6 +55,8 @@ class ScanInfo(BaseModel):
number_of_images: int
stitch_available: bool
dzi: Optional[str]
+ thing: str = "smart_scan"
+ card_type: Literal["Scan"] = "Scan"
class BaseScanData(BaseModel):
diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py
index b85b970e..8a2644f1 100644
--- a/src/openflexure_microscope_server/things/camera/__init__.py
+++ b/src/openflexure_microscope_server/things/camera/__init__.py
@@ -20,7 +20,7 @@ from typing import Any, Literal, Mapping, Optional, Self
import numpy as np
import piexif
-from fastapi import Response
+from fastapi import HTTPException, Response
from PIL import Image
from pydantic import BaseModel
@@ -62,6 +62,13 @@ BASE_IMAGE_FORMATS: dict[str, ImageFormatInfo] = {
}
+def _file_is_capture(filename: str) -> bool:
+ """Return whether this filename is a capture."""
+ return BASE_IMAGE_FORMATS["jpeg"].path_matches(filename) or BASE_IMAGE_FORMATS[
+ "png"
+ ].path_matches(filename)
+
+
class CaptureError(RuntimeError):
"""An error trying to capture from a CameraThing."""
@@ -199,6 +206,16 @@ class CaptureMode(BaseModel):
"""The resolution to save the image. Use None to save as captured."""
+class CaptureInfo(BaseModel):
+ """Summary information for the UI about an image."""
+
+ name: str
+ created: float
+ modified: float
+ thing: str
+ card_type: Literal["Capture"] = "Capture"
+
+
class BaseCamera(OFMThing, ABC):
"""The base class for all cameras. All cameras must directly inherit from this class.
@@ -275,6 +292,79 @@ class BaseCamera(OFMThing, ABC):
jpeg_data = await self.lores_mjpeg_stream.grab_frame()
return Response(content=jpeg_data, media_type="image/jpeg")
+ # Register with gallery.
+ _show_data_in_gallery = True
+
+ @property
+ def gallery_data_name(self) -> str:
+ """Name under which data shows up in gallery."""
+ return "Captures"
+
+ @property
+ def gallery_data_schema(self) -> type[CaptureInfo]:
+ """The schema (BaseModel) for passing data to the gallery."""
+ return CaptureInfo
+
+ def _all_captures(self) -> list[str]:
+ """Return the full path for all captures on disk."""
+ files = os.listdir(self._data_dir)
+ captures = []
+ for filename in files:
+ full_path = os.path.join(self.data_dir, filename)
+ if os.path.isfile(full_path) and _file_is_capture(filename):
+ captures.append(full_path)
+ return captures
+
+ def get_data_for_gallery(self) -> list[CaptureInfo]:
+ """Return all the information about the saved captures."""
+ return [
+ CaptureInfo(
+ name=os.path.basename(capture),
+ created=os.path.getctime(capture),
+ modified=os.path.getmtime(capture),
+ thing=self.name,
+ )
+ for capture in self._all_captures()
+ ]
+
+ def delete_all_gallery_items(self) -> None:
+ """Delete all the captures on the microscope.
+
+ Use with extreme caution.
+ """
+ for capture in self._all_captures():
+ lt.raise_if_cancelled()
+ self.logger.info(f"Deleting: {capture}")
+ os.remove(capture)
+
+ @lt.endpoint(
+ "delete",
+ "capture/{name}",
+ responses={
+ 200: {"description": "Successfully deleted capture"},
+ 400: {"description": "An error occurred while trying to delete capture"},
+ },
+ )
+ def delete_capture(self, name: str) -> None:
+ """Delete the specified capture.
+
+ This endpoint allows captures to be deleted from disk.
+
+ :param name: The name of the capture to delete
+ """
+ if not _file_is_capture(name):
+ self.logger.warning(f"{name} is not an image file.")
+ raise HTTPException(400, f"{name} is not an image file.")
+ full_path = os.path.normpath(os.path.join(self.data_dir, name))
+
+ try:
+ os.remove(full_path)
+ except IOError as e:
+ self.logger.warning(f"Failed to delete {name}.")
+ raise HTTPException(
+ 400, "Couldn't delete capture, check log for details"
+ ) from e
+
@property
def focus_fom(self) -> int:
"""Return the focus figure of merit.
diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py
index 88a6eaed..2f701c73 100644
--- a/src/openflexure_microscope_server/things/gallery.py
+++ b/src/openflexure_microscope_server/things/gallery.py
@@ -36,6 +36,8 @@ class GalleryCompatibleThing(Protocol):
# Ignore D102: No docstrings for the protocol.
def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102
+ def delete_all_gallery_items(self) -> None: ... # noqa: D102
+
class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery."""
@@ -95,5 +97,21 @@ class GalleryThing(lt.Thing):
"""
data_list = []
for thing in self.gallery_providing_things.values():
- data_list += [model.model_dump() for model in thing.get_data_for_gallery()]
+ try:
+ data_list += [
+ model.model_dump() for model in thing.get_data_for_gallery()
+ ]
+ except Exception as e:
+ # If any of the providers errors producing a list, log the exception
+ # and continue.
+ self.logger.exception(e)
return data_list
+
+ @lt.action
+ def delete_all_data(self) -> None:
+ """Delete all the gallery data on this microscope."""
+ for thing in self.gallery_providing_things.values():
+ try:
+ thing.delete_all_gallery_items()
+ except Exception as e:
+ self.logger.exception(e)
diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py
index 328a76cb..e51c84eb 100644
--- a/src/openflexure_microscope_server/things/smart_scan.py
+++ b/src/openflexure_microscope_server/things/smart_scan.py
@@ -192,6 +192,18 @@ class SmartScanThing(OFMThing):
ongoing=ongoing_name, include_ongoing=False
)
+ def delete_all_gallery_items(self) -> None:
+ """Delete all the scans on the microscope.
+
+ **This will irreversibly remove all scanned data from the microscope!**
+
+ Use with extreme caution.
+ """
+ for scan_name in self._scan_dir_manager.all_scans:
+ lt.raise_if_cancelled()
+ self.logger.info(f"Deleting: {scan_name}")
+ self._delete_scan(scan_name)
+
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
@@ -608,20 +620,6 @@ class SmartScanThing(OFMThing):
if not deleted_scan_success:
raise HTTPException(400, "Couldn't delete scan, check log for details")
- @lt.endpoint(
- "delete",
- "scans",
- )
- def delete_all_scans(self) -> None:
- """Delete all the scans on the microscope.
-
- **This will irreversibly remove all scanned data from the
- microscope!**
- Use with extreme caution.
- """
- for scan_name in self._scan_dir_manager.all_scans:
- self._delete_scan(scan_name)
-
@lt.action
def purge_empty_scans(self) -> None:
"""Delete all scan folders containing no images at the top level."""
diff --git a/tests/conftest.py b/tests/conftest.py
index cbeda0c1..09372412 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,6 +24,21 @@ REPO_ROOT = os.path.dirname(THIS_DIR)
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
+@pytest.fixture(autouse=True)
+def restore_root_logger():
+ """Restore the logging level of the root logger after each test.
+
+ As we change the root logging level if any code runs ``configure_logging``
+ this can make logs leak between tests.
+ """
+ root = logging.getLogger()
+ old_level = root.level
+
+ yield
+
+ root.setLevel(old_level)
+
+
@pytest.fixture
def simulation_test_env():
"""Yield a server with the configuration from the simulation json."""
diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py
index 10bc246d..7baf8f2f 100644
--- a/tests/unit_tests/test_gallery.py
+++ b/tests/unit_tests/test_gallery.py
@@ -1,9 +1,13 @@
"""Tests that captures have the expected metadata."""
import logging
+from dataclasses import dataclass
+from typing import Callable, Optional
+import pytest
from pydantic import BaseModel
+from labthings_fastapi.exceptions import InvocationCancelledError
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things import OFMThing
@@ -15,6 +19,16 @@ from openflexure_microscope_server.things.gallery import (
from ..shared_utils.lt_test_utils import LabThingsTestEnv
+def test_error_if_accessing_gallery_things_before_started():
+ """Check that gallery_providing_things property errors before init."""
+ thing = create_thing_without_server(GalleryThing)
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot access gallery_providing_things before server has started.",
+ ):
+ thing.gallery_providing_things
+
+
class MockGalleryData(BaseModel):
"""Mock gallery data."""
@@ -36,6 +50,9 @@ class MinimalGalleryClass:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
+ def delete_all_gallery_items(self) -> None:
+ """Mock deleting all the data."""
+
class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""Minimal example of a Thing that can show data in the gallery.
@@ -60,6 +77,9 @@ class BadGallerySource(OFMThing):
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
+ def delete_all_gallery_items(self) -> None:
+ """Mock deleting all the data."""
+
def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only."""
@@ -79,19 +99,27 @@ def test_gallery_compatible_protocol():
def test_gallery_thing_finds_all_providers(simulation_test_env):
"""Check the gallery identifies the correct Things that will provide data."""
+ # Started the full simulation in a test environment.
+ # Get the gallery and 3 other Things from the environment.
gallery = simulation_test_env.get_thing_by_name("gallery")
snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow")
smart_scan = simulation_test_env.get_thing_by_name("smart_scan")
camera = simulation_test_env.get_thing_by_name("camera")
+ # Check that all_ofm_things are correct. Camera and smart scan are ofm things
+ # snake workflow is not.
assert snake_workflow not in gallery.all_ofm_things.values()
assert camera in gallery.all_ofm_things.values()
assert smart_scan in gallery.all_ofm_things.values()
+ # Also check that both the camera and smart scan are recognised as gallery
+ # providers based on the protocol.
assert snake_workflow not in gallery.gallery_providing_things.values()
- assert camera not in gallery.gallery_providing_things.values()
+ assert camera in gallery.gallery_providing_things.values()
assert smart_scan in gallery.gallery_providing_things.values()
+ # Also check the runtime checkable protocol GalleryCompatibleThing works directly
+ # when called with isinstance.
assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing)
@@ -120,3 +148,119 @@ def test_gallery_logs_for_bad_providers(caplog):
record = caplog.records[0]
assert "Data from bad_thing cannot be shown in gallery" in record.message
assert record.levelname == "ERROR"
+
+
+@pytest.fixture
+def minimal_gallery_env():
+ """Return a minimal working environment for testing the gallery."""
+ things = {
+ "minimal_thing1": MinimalGallerySource,
+ "minimal_thing2": MinimalGallerySource,
+ "gallery": GalleryThing,
+ }
+ with LabThingsTestEnv(
+ things=things, application_config={"data_folder": "./openflexure/data/"}
+ ) as env:
+ return env
+
+
+def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
+ """Test that the gallery calls all gallery things to list data."""
+ # Create a config with the two minimal things and get the things by name.
+ gallery = minimal_gallery_env.get_thing_by_name("gallery")
+ thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1")
+ thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2")
+
+ # Add some mock data to them the gallery data things
+ thing_1_data = [
+ MockGalleryData(mock="thing_1", foobar=1),
+ MockGalleryData(mock="thing_1", foobar=2),
+ ]
+
+ thing_2_data = [
+ MockGalleryData(mock="thing_2", foobar=1),
+ MockGalleryData(mock="thing_2", foobar=2),
+ ]
+
+ # Also mock the get_data_for_gallery methods.
+ mocker.patch.object(thing_1, "get_data_for_gallery", return_value=thing_1_data)
+ mocker.patch.object(thing_2, "get_data_for_gallery", return_value=thing_2_data)
+
+ # Set the expected returns.
+ thing_1_return = [
+ {"mock": "thing_1", "foobar": 1},
+ {"mock": "thing_1", "foobar": 2},
+ ]
+ thing_2_return = [
+ {"mock": "thing_2", "foobar": 1},
+ {"mock": "thing_2", "foobar": 2},
+ ]
+
+ # Check that the gallery returns the expected concatenated data
+ assert gallery.list_data == thing_1_return + thing_2_return
+
+ # Check that if the first thing called throws an error the data from the second
+ # thing is still returned.
+ thing_1.get_data_for_gallery.side_effect = RuntimeError
+ with caplog.at_level(logging.INFO):
+ assert gallery.list_data == thing_2_return
+ # Check an error was logged about the first thing failing to collect data.
+ assert len(caplog.records) == 1
+ assert caplog.records[0].levelname == "ERROR"
+
+
+@dataclass
+class GalleryDeleteTestCase:
+ """Inputs and expected outputs for testing ``save_from_memory``.
+
+ The default save kwargs assume a jpeg.
+ """
+
+ thing_1_side_effect: Optional[Callable]
+ thing_2_call_count: int
+ # Expect directly calling the action will raise the thing 1 side_effect error
+ action_errors: bool
+
+
+GALLERY_DELETE_TEST_CASES = [
+ # No errors from thing 1, thing 2 gets called
+ GalleryDeleteTestCase(
+ thing_1_side_effect=None, thing_2_call_count=1, action_errors=False
+ ),
+ # Thing 1 errors, thing 2 is still called
+ GalleryDeleteTestCase(
+ thing_1_side_effect=RuntimeError, thing_2_call_count=1, action_errors=False
+ ),
+ # Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised
+ GalleryDeleteTestCase(
+ thing_1_side_effect=InvocationCancelledError,
+ thing_2_call_count=0,
+ action_errors=True,
+ ),
+]
+
+
+@pytest.mark.parametrize("test_case", GALLERY_DELETE_TEST_CASES)
+def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker):
+ """Test that the gallery deletes on all gallery providing things."""
+ # Create a config with the minimal Thing and the bad Thing
+ gallery = minimal_gallery_env.get_thing_by_name("gallery")
+ thing_1 = minimal_gallery_env.get_thing_by_name("minimal_thing1")
+ thing_2 = minimal_gallery_env.get_thing_by_name("minimal_thing2")
+
+ # Set up the desired side effect for the test case.
+ mocker.patch.object(
+ thing_1, "delete_all_gallery_items", side_effect=test_case.thing_1_side_effect
+ )
+ mocker.patch.object(thing_2, "delete_all_gallery_items")
+
+ # Call delete_all_data, checking for errors if the test case expects an error.
+ if test_case.action_errors:
+ with pytest.raises(test_case.thing_1_side_effect):
+ gallery.delete_all_data()
+ else:
+ gallery.delete_all_data()
+
+ # Check the call counts are as expected from the test case.
+ assert thing_1.delete_all_gallery_items.call_count == 1
+ assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count
diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py
index 8b93152d..0be10c6d 100644
--- a/tests/unit_tests/test_simulated_camera.py
+++ b/tests/unit_tests/test_simulated_camera.py
@@ -1,6 +1,13 @@
-"""Test the functionality specific to the simulated camera."""
+"""Test the the simulated camera.
+
+This includes both unit tests for functions specific to the simulation camera or
+base camera functionality that is not camera specific, but requires the camera to
+start and take images.
+"""
import logging
+import os
+import tempfile
import time
from io import BytesIO
@@ -14,7 +21,7 @@ from pydantic import ValidationError
import labthings_fastapi as lt
from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV
-from openflexure_microscope_server.things.camera import simulation
+from openflexure_microscope_server.things.camera import CaptureInfo, simulation
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage
@@ -279,3 +286,110 @@ def test_generate_image_output_size(camera):
img = camera.generate_image(pos)
assert img.size == (camera.shape[1], camera.shape[0])
+
+
+def test_gallery_responses(camera, mocker, caplog):
+ """Check camera responds to gallery as expected."""
+ mock_raise_if_cancelled = mocker.patch(
+ "openflexure_microscope_server.things.camera.lt.raise_if_cancelled",
+ )
+
+ with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO):
+ camera._data_dir = tmpdir
+
+ assert camera.gallery_data_name == "Captures"
+ assert camera.gallery_data_schema == CaptureInfo
+
+ # When we start there are no captures
+ assert camera.get_data_for_gallery() == []
+
+ filenames = [
+ "doe.png",
+ "ray.png",
+ "me.jpg",
+ "far.jpeg",
+ "so.txt",
+ "la.json",
+ "tea.leaf",
+ ]
+ images = filenames[:4]
+ non_images = filenames[4:]
+
+ for filename in filenames:
+ with open(os.path.join(camera._data_dir, filename), "w") as f_obj:
+ f_obj.write("mock")
+
+ # Check all files written
+ assert set(os.listdir(camera._data_dir)) == set(filenames)
+
+ # Check only images are returned by get_data_for_galler
+ captures = camera.get_data_for_gallery()
+ assert {capture.name for capture in captures} == set(images)
+
+ # delete everything
+ camera.delete_all_gallery_items()
+
+ # No captures remain
+ assert camera.get_data_for_gallery() == []
+
+ # Non-images not deleted
+ assert set(os.listdir(camera._data_dir)) == set(non_images)
+
+ assert len(caplog.records) == len(images)
+ for record in caplog.records:
+ assert record.message.startswith("Deleting: ")
+ assert mock_raise_if_cancelled.call_count == len(images)
+
+
+def test_delete_capture(test_env, caplog):
+ """Check camera deletes images when requested."""
+ camera = test_env.get_thing_by_type(SimulatedCamera)
+ http_client = test_env.get_thing_client("camera").client
+
+ with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.WARNING):
+ cam_dir = os.path.join(tmpdir, "camera")
+ os.makedirs(cam_dir)
+ camera._data_dir = cam_dir
+
+ filenames = [
+ os.path.join(cam_dir, "foo.png"),
+ # Make an image outside the camera dir
+ os.path.join(tmpdir, "external.png"),
+ os.path.join(cam_dir, "bar.txt"),
+ ]
+
+ for filename in filenames:
+ with open(filename, "w") as f_obj:
+ f_obj.write("mock")
+
+ # Check files created.
+ assert "foo.png" in os.listdir(camera._data_dir)
+ assert "bar.txt" in os.listdir(camera._data_dir)
+ assert os.path.isfile(os.path.join(tmpdir, "external.png"))
+
+ response = http_client.delete("camera/capture/foo.png")
+ assert response.status_code == 200 # OK
+ # File is gone
+ assert "foo.png" not in os.listdir(camera._data_dir)
+ # No longs
+ assert len(caplog.records) == 0
+
+ # Try to be evil and delete the file outside the cameras data dir"
+ response = http_client.delete(r"camera/capture/%2E%2E%2Ffoo.png")
+ assert response.status_code == 404 # Route not allowed by fastAPI
+ # No records
+ assert len(caplog.records) == 0
+
+ # Try to delete an image that doesn't exist
+ response = http_client.delete("camera/capture/fake.png")
+ assert response.status_code == 400 # Error deleting as it doesn't exist
+ # A new log
+ assert len(caplog.records) == 1
+
+ # Try to delete something that isn't an image.
+ response = http_client.delete("camera/capture/bar.txt")
+ assert response.status_code == 400 # Error deleting as it isn't and image
+ # File still exists.
+ assert "bar.txt" in os.listdir(camera._data_dir)
+ # A new log
+ assert len(caplog.records) == 2
diff --git a/tests/unit_tests/test_smart_scan.py b/tests/unit_tests/test_smart_scan.py
index d8fe3038..404165da 100644
--- a/tests/unit_tests/test_smart_scan.py
+++ b/tests/unit_tests/test_smart_scan.py
@@ -281,8 +281,11 @@ def test_public_delete_scan(entered_smart_scan_thing, caplog):
assert len(caplog.records) == 1
-def test_delete_all_scans(entered_smart_scan_thing, caplog):
+def test_delete_all_scans(entered_smart_scan_thing, caplog, mocker):
"""Check the delete_all_scan API really does delete all the scans."""
+ mock_raise_if_cancelled = mocker.patch(
+ "openflexure_microscope_server.things.smart_scan.lt.raise_if_cancelled",
+ )
smart_scan_thing = entered_smart_scan_thing
with caplog.at_level(logging.INFO):
fake_scan_names = [
@@ -295,12 +298,17 @@ def test_delete_all_scans(entered_smart_scan_thing, caplog):
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
os.makedirs(fake_scan_path)
assert os.path.exists(fake_scan_path)
- smart_scan_thing.delete_all_scans()
+ smart_scan_thing.delete_all_gallery_items()
for fake_scan_name in fake_scan_names:
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
assert not os.path.exists(fake_scan_path)
- # No logs generated
- assert len(caplog.records) == 0
+ # One log generated per scan deleted
+ assert len(caplog.records) == 4
+ for record in caplog.records:
+ assert record.message.startswith("Deleting: fake_scan_000")
+ assert record.levelname == "INFO"
+ # Check that raise_if_cancelled is called once for each scan.
+ assert mock_raise_if_cancelled.call_count == 4
def _run_only_outer_scan(
diff --git a/webapp/src/components/genericComponents/toggleSwitch.vue b/webapp/src/components/genericComponents/toggleSwitch.vue
new file mode 100644
index 00000000..a3fb6746
--- /dev/null
+++ b/webapp/src/components/genericComponents/toggleSwitch.vue
@@ -0,0 +1,123 @@
+
+ {{ labelText }} {{ stateLabelText }} Image Capture
Image Capture
- -