And adjust for new as lt import

This commit is contained in:
Julian Stirling 2025-06-28 17:57:31 +01:00
parent 840ed7f20e
commit 52a80ef66b
17 changed files with 240 additions and 293 deletions

View file

@ -3,14 +3,16 @@ from __future__ import annotations
from typing import Optional
from copy import copy
from labthings_fastapi.server import cli, ThingServer
import labthings_fastapi as lt
import uvicorn
from .serve_static_files import add_static_files
from .legacy_api import add_v2_endpoints
from ..logging import configure_logging, retrieve_log, retrieve_log_from_file
def customise_server(server: ThingServer, log_folder: str, scans_folder: Optional[str]):
def customise_server(
server: lt.ThingServer, log_folder: str, scans_folder: Optional[str]
):
"""Customise the server with additional endpoints, etc."""
configure_logging(log_folder)
add_v2_endpoints(server)
@ -39,7 +41,7 @@ def _get_scans_dir(config: dict) -> Optional[str]:
def serve_from_cli(argv: Optional[list[str]] = None):
"""Start the server from the command line"""
args = cli.parse_args(argv)
args = lt.cli.parse_args(argv)
log_config = copy(uvicorn.config.LOGGING_CONFIG)
log_config["loggers"]["uvicorn"]["propagate"] = True
@ -50,10 +52,10 @@ def serve_from_cli(argv: Optional[list[str]] = None):
config = None
server = None
try:
config = cli.config_from_args(args)
config = lt.cli.config_from_args(args)
log_folder = config.get("log_folder", "./openflexure/logs")
scans_folder = _get_scans_dir(config)
server = cli.server_from_config(config)
server = lt.cli.server_from_config(config)
customise_server(server, log_folder, scans_folder)
uvicorn.run(
server.app,
@ -68,7 +70,7 @@ def serve_from_cli(argv: Optional[list[str]] = None):
print(f"Error: {e}")
fallback_server = "labthings_fastapi.server.fallback:app"
print(f"Starting fallback server {fallback_server}.")
app = cli.object_reference_to_object(fallback_server)
app = lt.cli.object_reference_to_object(fallback_server)
app.labthings_config = config
app.labthings_server = server
app.labthings_error = e

View file

@ -1,9 +1,9 @@
from . import ThingServer
import labthings_fastapi as lt
from fastapi import Response
from socket import gethostname
def add_v2_endpoints(thing_server: ThingServer):
def add_v2_endpoints(thing_server: lt.ThingServer):
app = thing_server.app
# TODO: update openflexure connect to make this unnecessary!!

View file

@ -1,19 +1,20 @@
import numpy as np
import logging
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.decorators import thing_action
import labthings_fastapi as lt
from .stage import StageDependency as StageDep
from openflexure_microscope_server.things.autofocus import AutofocusThing
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
CSMDep = lt.deps.direct_thing_client_dependency(
CameraStageMapper, "/camera_stage_mapping/"
)
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class RecentringThing(Thing):
@thing_action
class RecentringThing(lt.Thing):
@lt.thing_action
def recentre(
self,
autofocus: AutofocusDep,

View file

@ -17,10 +17,7 @@ from fastapi import Depends
import numpy as np
from pydantic import BaseModel
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.descriptors import ThingSetting
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from .camera import RawCameraDependency as Camera
@ -190,7 +187,7 @@ class SharpnessDataArrays(BaseModel):
class JPEGSharpnessMonitor:
def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal):
def __init__(self, stage: Stage, camera: Camera, portal: lt.deps.BlockingPortal):
self.camera = camera
self.stage = stage
self.portal = portal
@ -288,14 +285,14 @@ class JPEGSharpnessMonitor:
SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()]
class AutofocusThing(Thing):
class AutofocusThing(lt.Thing):
"""The Thing concerned with combinations of z axis movements and the camera.
Actions here involve moving a stage in z, and using the camera to either
capture images (generally, z-stacking) and measuring the sharpness of the
field of view to assess focus (autofocus and testing the success of a z-stack)"""
@thing_action
@lt.thing_action
def fast_autofocus(
self,
sharpness_monitor: SharpnessMonitorDep,
@ -325,7 +322,7 @@ class AutofocusThing(Thing):
# Return all focus data
return sharpness_monitor.data_dict()
@thing_action
@lt.thing_action
def z_move_and_measure_sharpness(
self,
sharpness_monitor: SharpnessMonitorDep,
@ -351,7 +348,7 @@ class AutofocusThing(Thing):
sharpness_monitor.focus_rel(current_dz)
return sharpness_monitor.data_dict()
@thing_action
@lt.thing_action
def looping_autofocus(
self,
stage: Stage,
@ -397,7 +394,7 @@ class AutofocusThing(Thing):
stage.move_absolute(z=peak_height)
return heights.tolist(), sizes.tolist()
stack_images_to_save = ThingSetting(
stack_images_to_save = lt.ThingSetting(
initial_value=1,
model=int,
description="""The number of images to save in a stack.
@ -405,7 +402,7 @@ class AutofocusThing(Thing):
Defaults to 1 unless you need to see either side of focus""",
)
stack_min_images_to_test = ThingSetting(
stack_min_images_to_test = lt.ThingSetting(
initial_value=9,
model=int,
description="""The minimum number of images to capture in a stack.
@ -419,7 +416,7 @@ class AutofocusThing(Thing):
""",
)
stack_dz = ThingSetting(
stack_dz = lt.ThingSetting(
initial_value=50,
model=int,
description="""Space in steps between images in a z-stack
@ -428,7 +425,7 @@ class AutofocusThing(Thing):
200 for 20x""",
)
@thing_action
@lt.thing_action
def run_smart_stack(
self,
cam: WrappedCamera,

View file

@ -5,9 +5,7 @@ from PIL import Image
from pydantic import BaseModel
from scipy.stats import norm
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_setting
from labthings_fastapi.descriptors import ThingSetting
import labthings_fastapi as lt
from .camera import CameraDependency as CamDep
@ -17,12 +15,12 @@ class ChannelDistributions(BaseModel):
colorspace: str = "LUV"
class BackgroundDetectThing(Thing):
class BackgroundDetectThing(lt.Thing):
# Requires a getter and a setter to support being a BaseModel but being
# saved to file as a dict
_background_distributions: Optional[ChannelDistributions] = None
@thing_setting
@lt.thing_setting
def background_distributions(self) -> Optional[ChannelDistributions]:
"""The statistics of the background image"""
bd = self._background_distributions
@ -45,13 +43,13 @@ class BackgroundDetectThing(Thing):
f"Cannot set background_distributions with an object of type {type(value)}"
)
tolerance = ThingSetting(
tolerance = lt.ThingSetting(
initial_value=7.0,
model=float,
description="How many standard deviations to allow for the background",
)
fraction = ThingSetting(
fraction = lt.ThingSetting(
initial_value=25.0,
model=float,
description="How much of the image needs to be not background to label as sample",
@ -78,7 +76,7 @@ class BackgroundDetectThing(Thing):
axis=2,
)
@thing_action
@lt.thing_action
def background_fraction(self, cam: CamDep) -> float:
"""Determine what fraction of the current image is background
@ -96,7 +94,7 @@ class BackgroundDetectThing(Thing):
mask = self.background_mask(current_image_luv)
return np.count_nonzero(mask) / np.prod(mask.shape) * 100
@thing_action
@lt.thing_action
def image_is_sample(self, cam: CamDep) -> bool:
"""Label the current image as either background or sample"""
b_fraction = self.background_fraction(cam)
@ -104,7 +102,7 @@ class BackgroundDetectThing(Thing):
return (100 - b_fraction) > fraction_threshold
@thing_action
@lt.thing_action
def set_background(self, cam: CamDep):
"""Grab an image, and use its statistics to set the background

View file

@ -1,6 +1,6 @@
"""OpenFlexure Microscope Camera
This module defines the interface for cameras. Any compatible Thing
This module defines the interface for cameras. Any compatible lt.Thing
should enabe the server to work.
See repository root for licensing information.
@ -14,23 +14,15 @@ from pydantic import RootModel
from PIL import Image
import piexif
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency
from labthings_fastapi.dependencies.invocation import InvocationLogger
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
from labthings_fastapi.outputs.blob import Blob
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
class JPEGBlob(Blob):
class JPEGBlob(lt.blob.Blob):
media_type: str = "image/jpeg"
class PNGBlob(Blob):
class PNGBlob(lt.blob.Blob):
"""A class representing a PNG image as a LabThings FastAPI Blob"""
media_type: str = "image/png"
@ -154,11 +146,11 @@ class CameraMemoryBuffer:
del self._storage[key]
class BaseCamera(Thing):
class BaseCamera(lt.Thing):
"""The base class for all cameras. All cameras must directly inherit from this class"""
mjpeg_stream = MJPEGStreamDescriptor()
lores_mjpeg_stream = MJPEGStreamDescriptor()
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
_memory_buffer = CameraMemoryBuffer()
def __enter__(self) -> None:
@ -167,7 +159,7 @@ class BaseCamera(Thing):
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
raise NotImplementedError("CameraThings must define their own __exit__ method")
@thing_action
@lt.thing_action
def start_streaming(
self, main_resolution: tuple[int, int], buffer_count: int
) -> None:
@ -177,14 +169,14 @@ class BaseCamera(Thing):
"CameraThings must define their own start_streaming method"
)
@thing_property
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
raise NotImplementedError(
"CameraThings must define their own stream_active method"
)
@thing_action
@lt.thing_action
def capture_array(
self,
stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -194,10 +186,10 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_array method"
)
@thing_action
@lt.thing_action
def capture_jpeg(
self,
metadata_getter: GetThingStates,
metadata_getter: lt.deps.GetThingStates,
resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 5,
) -> JPEGBlob:
@ -206,10 +198,10 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_jpeg method"
)
@thing_action
@lt.thing_action
def grab_jpeg(
self,
portal: BlockingPortal,
portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main",
) -> JPEGBlob:
"""Acquire one image from the preview stream and return as an array
@ -225,10 +217,10 @@ class BaseCamera(Thing):
frame = portal.call(stream.grab_frame)
return JPEGBlob.from_bytes(frame)
@thing_action
@lt.thing_action
def grab_jpeg_size(
self,
portal: BlockingPortal,
portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main",
) -> int:
"""Acquire one image from the preview stream and return its size"""
@ -237,7 +229,7 @@ class BaseCamera(Thing):
)
return portal.call(stream.next_frame_size)
@thing_action
@lt.thing_action
def capture_image(
self,
stream_name: Literal["main", "lores", "raw"],
@ -248,12 +240,12 @@ class BaseCamera(Thing):
"CameraThings must define their own capture_image method"
)
@thing_action
@lt.thing_action
def capture_and_save(
self,
jpeg_path: str,
logger: InvocationLogger,
metadata_getter: GetThingStates,
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
@ -279,11 +271,11 @@ class BaseCamera(Thing):
save_resolution,
)
@thing_action
@lt.thing_action
def capture_to_memory(
self,
logger: InvocationLogger,
metadata_getter: GetThingStates,
logger: lt.deps.InvocationLogger,
metadata_getter: lt.deps.GetThingStates,
buffer_max: int = 1,
) -> None:
"""
@ -304,11 +296,11 @@ class BaseCamera(Thing):
image, metadata = self._robust_image_capture(metadata_getter, logger)
return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max)
@thing_action
@lt.thing_action
def save_from_memory(
self,
jpeg_path: str,
logger: InvocationLogger,
logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None,
buffer_id: Optional[int] = None,
) -> None:
@ -333,15 +325,15 @@ class BaseCamera(Thing):
save_resolution=save_resolution,
)
@thing_action
@lt.thing_action
def clear_buffers(self) -> None:
"""Clear all images in memory"""
self._memory_buffer.clear()
def _robust_image_capture(
self,
metadata_getter: GetThingStates,
logger: InvocationLogger,
metadata_getter: lt.deps.GetThingStates,
logger: lt.deps.InvocationLogger,
) -> Image:
"""Capture an image in memory and return it with metadata
CaptureError raised if the capture fails for any reason
@ -366,7 +358,7 @@ class BaseCamera(Thing):
jpeg_path: str,
image: Image,
metadata: dict,
logger: InvocationLogger,
logger: lt.deps.InvocationLogger,
save_resolution: Optional[Tuple[int, int]] = None,
) -> None:
"""Saving the captured image and metadata to disk
@ -399,5 +391,5 @@ class BaseCamera(Thing):
raise IOError(f"An error occurred while saving {jpeg_path}") from e
CameraDependency = direct_thing_client_dependency(BaseCamera, "/camera/")
RawCameraDependency = raw_thing_dependency(BaseCamera)
CameraDependency = lt.deps.direct_thing_client_dependency(BaseCamera, "/camera/")
RawCameraDependency = lt.deps.raw_thing_dependency(BaseCamera)

View file

@ -16,9 +16,7 @@ from threading import Thread
import cv2
import piexif
from labthings_fastapi.utilities import get_blocking_portal
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.metadata import GetThingStates
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray
from . import BaseCamera, JPEGBlob
@ -45,7 +43,7 @@ class OpenCVCamera(BaseCamera):
self._capture_thread.join()
self.cap.release()
@thing_property
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
if self._capture_enabled and self._capture_thread:
@ -53,7 +51,7 @@ class OpenCVCamera(BaseCamera):
return False
def _capture_frames(self):
portal = get_blocking_portal(self)
portal = lt.get_blocking_portal(self)
while self._capture_enabled:
ret, frame = self.cap.read()
if not ret:
@ -68,7 +66,7 @@ class OpenCVCamera(BaseCamera):
].tobytes()
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
@thing_action
@lt.thing_action
def capture_array(
self,
resolution: Literal["main", "full"] = "full",
@ -87,10 +85,10 @@ class OpenCVCamera(BaseCamera):
)
return frame
@thing_action
@lt.thing_action
def capture_jpeg(
self,
metadata_getter: GetThingStates,
metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main",
) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob

View file

@ -16,37 +16,31 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf
"""
from __future__ import annotations
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional
from datetime import datetime
import json
import logging
import os
import tempfile
import time
from tempfile import TemporaryDirectory
from contextlib import contextmanager
from threading import RLock
from pydantic import BaseModel, BeforeValidator
from labthings_fastapi.descriptors.property import ThingProperty
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStream
from labthings_fastapi.utilities import get_blocking_portal
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
from typing import Annotated, Any, Iterator, Literal, Mapping, Optional
from contextlib import contextmanager
import piexif
from threading import RLock
import picamera2
import numpy as np
from picamera2 import Picamera2
from picamera2.encoders import MJPEGEncoder
from picamera2.outputs import Output
import numpy as np
from . import picamera_recalibrate_utils as recalibrate_utils
import labthings_fastapi as lt
from . import picamera_recalibrate_utils as recalibrate_utils
from . import BaseCamera, JPEGBlob, ArrayModel
class PicameraControl(ThingProperty):
class PicameraControl(lt.ThingProperty):
def __init__(
self, control_name: str, model: type = float, description: Optional[str] = None
):
@ -66,7 +60,7 @@ class PicameraControl(ThingProperty):
class PicameraStreamOutput(Output):
"""An Output class that sends frames to a stream"""
def __init__(self, stream: MJPEGStream, portal: BlockingPortal):
def __init__(self, stream: lt.outputs.MJPEGStream, portal: lt.deps.BlockingPortal):
"""Create an output that puts frames in an MJPEGStream
We need to pass the stream object, and also the blocking portal, because
@ -211,19 +205,19 @@ class StreamingPiCamera2(BaseCamera):
except KeyError:
pass # If controls are missing, leave at default
stream_resolution = ThingProperty(
stream_resolution = lt.ThingProperty(
tuple[int, int],
initial_value=(820, 616),
description="Resolution to use for the MJPEG stream",
)
mjpeg_bitrate = ThingProperty(
mjpeg_bitrate = lt.ThingProperty(
Optional[int],
initial_value=100000000,
description="Bitrate for MJPEG stream (None for default)",
)
stream_active = ThingProperty(
stream_active = lt.ThingProperty(
bool,
initial_value=False,
description="Whether the MJPEG stream is active",
@ -255,7 +249,7 @@ class StreamingPiCamera2(BaseCamera):
_sensor_modes = None
@thing_property
@lt.thing_property
def sensor_modes(self) -> list[SensorMode]:
"""All the available modes the current sensor supports"""
if not self._sensor_modes:
@ -263,7 +257,7 @@ class StreamingPiCamera2(BaseCamera):
self._sensor_modes = cam.sensor_modes
return self._sensor_modes
@thing_property
@lt.thing_property
def sensor_mode(self) -> Optional[SensorModeSelector]:
"""The intended sensor mode of the camera"""
return self.thing_settings["sensor_mode"]
@ -276,13 +270,13 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera(pause_stream=True):
self.thing_settings["sensor_mode"] = new_mode
@thing_property
@lt.thing_property
def sensor_resolution(self) -> tuple[int, int]:
"""The native resolution of the camera's sensor"""
with self.picamera() as cam:
return cam.sensor_resolution
tuning = ThingProperty(Optional[dict], None, readonly=True)
tuning = lt.ThingProperty(Optional[dict], None, readonly=True)
def settings_to_properties(self):
"""Set the values of properties based on the settings dict"""
@ -400,7 +394,7 @@ class StreamingPiCamera2(BaseCamera):
cam.close()
del self._picamera
@thing_action
@lt.thing_action
def start_streaming(
self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6
) -> None:
@ -451,7 +445,7 @@ class StreamingPiCamera2(BaseCamera):
MJPEGEncoder(self.mjpeg_bitrate),
PicameraStreamOutput(
self.mjpeg_stream,
get_blocking_portal(self),
lt.get_blocking_portal(self),
),
name=stream_name,
)
@ -459,7 +453,7 @@ class StreamingPiCamera2(BaseCamera):
MJPEGEncoder(100000000),
PicameraStreamOutput(
self.lores_mjpeg_stream,
get_blocking_portal(self),
lt.get_blocking_portal(self),
),
name="lores",
)
@ -472,7 +466,7 @@ class StreamingPiCamera2(BaseCamera):
"Started MJPEG stream at %s on port %s", self.stream_resolution, 1
)
@thing_action
@lt.thing_action
def stop_streaming(self, stop_web_stream: bool = True) -> None:
"""
Stop the MJPEG stream
@ -493,7 +487,7 @@ class StreamingPiCamera2(BaseCamera):
# Adding a sleep to prevent camera getting confused by rapid commands
time.sleep(0.2)
@thing_action
@lt.thing_action
def capture_image(
self,
stream_name: Literal["main", "lores", "raw"] = "main",
@ -511,7 +505,7 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam:
return cam.capture_image(stream_name, wait=wait)
@thing_action
@lt.thing_action
def capture_array(
self,
stream_name: Literal["main", "lores", "raw", "full"] = "main",
@ -538,7 +532,7 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam:
return cam.capture_array(stream_name, wait=wait)
@thing_property
@lt.thing_property
def camera_configuration(self) -> Mapping:
"""The "configuration" dictionary of the picamera2 object
@ -553,10 +547,10 @@ class StreamingPiCamera2(BaseCamera):
with self.picamera() as cam:
return cam.camera_configuration()
@thing_action
@lt.thing_action
def capture_jpeg(
self,
metadata_getter: GetThingStates,
metadata_getter: lt.deps.GetThingStates,
resolution: Literal["lores", "main", "full"] = "main",
wait: Optional[float] = 0.9,
) -> JPEGBlob:
@ -580,7 +574,7 @@ class StreamingPiCamera2(BaseCamera):
bypass this, you must use a raw capture.
"""
fname = datetime.now().strftime("%Y-%m-%d-%H%M%S.jpeg")
folder = TemporaryDirectory()
folder = tempfile.TemporaryDirectory()
path = os.path.join(folder.name, fname)
config = self.camera_configuration
# Low-res and main streams are running already - so we don't need
@ -609,13 +603,13 @@ class StreamingPiCamera2(BaseCamera):
piexif.insert(piexif.dump(exif_dict), path)
return JPEGBlob.from_temporary_directory(folder, fname)
@thing_property
@lt.thing_property
def capture_metadata(self) -> dict:
"""Return the metadata from the camera"""
with self.picamera() as cam:
return cam.capture_metadata()
@thing_action
@lt.thing_action
def auto_expose_from_minimum(
self,
target_white_level: int = 700,
@ -641,7 +635,7 @@ class StreamingPiCamera2(BaseCamera):
)
self.update_persistent_controls()
@thing_action
@lt.thing_action
def calibrate_white_balance(
self,
method: Literal["percentile", "centre"] = "centre",
@ -676,7 +670,7 @@ class StreamingPiCamera2(BaseCamera):
)
self.update_persistent_controls()
@thing_action
@lt.thing_action
def calibrate_lens_shading(self) -> None:
"""Take an image and use it for flat-field correction.
@ -694,7 +688,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_lst(self.tuning, L, Cr, Cb)
self.initialise_picamera()
@thing_property
@lt.thing_property
def colour_correction_matrix(
self,
) -> tuple[float, float, float, float, float, float, float, float, float]:
@ -709,7 +703,7 @@ class StreamingPiCamera2(BaseCamera):
self.thing_settings["colour_correction_matrix"] = value
self.calibrate_colour_correction(value)
@thing_action
@lt.thing_action
def reset_ccm(self):
"""
Overwrite the colour correction matrix in camera tuning with default values.
@ -731,7 +725,7 @@ class StreamingPiCamera2(BaseCamera):
]
self.colour_correction_matrix = col_corr_matrix
@thing_action
@lt.thing_action
def calibrate_colour_correction(
self,
col_corr_matrix: tuple[
@ -750,7 +744,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_ccm(self.tuning, col_corr_matrix)
self.initialise_picamera()
@thing_action
@lt.thing_action
def set_static_green_equalisation(self, offset: int = 65535) -> None:
"""Set the green equalisation to a static value.
@ -765,7 +759,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_geq(self.tuning, offset)
self.initialise_picamera()
@thing_action
@lt.thing_action
def full_auto_calibrate(self) -> None:
"""Perform a full auto-calibration
@ -783,7 +777,7 @@ class StreamingPiCamera2(BaseCamera):
self.calibrate_lens_shading()
self.calibrate_white_balance()
@thing_action
@lt.thing_action
def flat_lens_shading(self) -> None:
"""Disable flat-field correction
@ -802,7 +796,7 @@ class StreamingPiCamera2(BaseCamera):
)
self.initialise_picamera()
@thing_property
@lt.thing_property
def lens_shading_tables(self) -> Optional[LensShading]:
"""The current lens shading (i.e. flat-field correction)
@ -879,7 +873,7 @@ class StreamingPiCamera2(BaseCamera):
float(gain_b / np.max(lst.Cb)),
)
@thing_action
@lt.thing_action
def flat_lens_shading_chrominance(self) -> None:
"""Disable flat-field correction
@ -894,7 +888,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.set_static_lst(self.tuning, luminance, flat, flat)
self.initialise_picamera()
@thing_action
@lt.thing_action
def reset_lens_shading(self) -> None:
"""Revert to default lens shading settings
@ -905,7 +899,7 @@ class StreamingPiCamera2(BaseCamera):
recalibrate_utils.copy_alsc_section(self.default_tuning, self.tuning)
self.initialise_picamera()
@thing_property
@lt.thing_property
def lens_shading_is_static(self) -> bool:
"""Whether the lens shading is static

View file

@ -19,10 +19,7 @@ import numpy as np
import piexif
from scipy.ndimage import gaussian_filter
from labthings_fastapi.utilities import get_blocking_portal
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.server import ThingServer
import labthings_fastapi as lt
from . import BaseCamera, JPEGBlob, ArrayModel
from ..stage import BaseStage
@ -36,7 +33,7 @@ class SimulatedCamera(BaseCamera):
"""A Thing representing an OpenCV camera"""
_stage: Optional[BaseStage] = None
_server: Optional[ThingServer] = None
_server: Optional[lt.ThingServer] = None
def __init__(
self,
@ -116,7 +113,7 @@ class SimulatedCamera(BaseCamera):
return image
def attach_to_server(
self, server: ThingServer, path: str, setting_storage_path: str
self, server: lt.ThingServer, path: str, setting_storage_path: str
):
self._server = server
return super().attach_to_server(server, path, setting_storage_path)
@ -146,7 +143,7 @@ class SimulatedCamera(BaseCamera):
self._capture_enabled = False
self._capture_thread.join()
@thing_property
@lt.thing_property
def stream_active(self) -> bool:
"Whether the MJPEG stream is active"
if self._capture_enabled and self._capture_thread:
@ -154,7 +151,7 @@ class SimulatedCamera(BaseCamera):
return False
def _capture_frames(self):
portal = get_blocking_portal(self)
portal = lt.get_blocking_portal(self)
while self._capture_enabled:
time.sleep(self.frame_interval)
try:
@ -168,7 +165,7 @@ class SimulatedCamera(BaseCamera):
except Exception as e:
logging.error(f"Failed to capture frame: {e}, retrying...")
@thing_action
@lt.thing_action
def capture_array(
self,
resolution: Literal["main", "full"] = "full",
@ -182,10 +179,10 @@ class SimulatedCamera(BaseCamera):
logging.warning(f"Simulation camera doen't respect {resolution} setting")
return self.generate_frame()
@thing_action
@lt.thing_action
def capture_jpeg(
self,
metadata_getter: GetThingStates,
metadata_getter: lt.deps.GetThingStates,
resolution: Literal["main", "full"] = "main",
) -> JPEGBlob:
"""Acquire one image from the camera and return as a JPEG blob

View file

@ -33,14 +33,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import (
image_to_stage_displacement_from_1d,
)
from camera_stage_mapping.exceptions import MappingError
from labthings_fastapi.dependencies.invocation import (
InvocationCancelledError,
InvocationLogger,
)
import labthings_fastapi as lt
from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.descriptors import ThingSetting
from labthings_fastapi.thing import Thing
from camera_stage_mapping.camera_stage_tracker import Tracker
from .camera import CameraDependency as Camera
from .stage import StageDependency as Stage
@ -179,15 +175,15 @@ class CSMUncalibratedError(HTTPException):
)
class CameraStageMapper(Thing):
class CameraStageMapper(lt.Thing):
"""A Thing to manage mapping between image and stage coordinates"""
@thing_action
@lt.thing_action
def calibrate_1d(
self,
hw: HardwareInterfaceDep,
stage: Stage,
logger: InvocationLogger,
logger: lt.deps.InvocationLogger,
direction: Tuple[float, float, float],
) -> DenumpifyingDict:
"""Move a microscope's stage in 1D, and figure out the relationship with the camera"""
@ -202,7 +198,7 @@ class CameraStageMapper(Thing):
result: dict = calibrate_backlash_1d(
tracker, move, direction_array, logger=logger
)
except InvocationCancelledError as e:
except lt.exceptions.InvocationCancelledError as e:
logger.info("User cancelled the camera stage mapping calibration")
logger.info("Returning to starting position")
stage.move_absolute(**starting_position, block_cancellation=True)
@ -215,9 +211,9 @@ class CameraStageMapper(Thing):
result["image_resolution"] = hw.grab_image().shape[:2]
return result
@thing_action
@lt.thing_action
def calibrate_xy(
self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger
self, hw: HardwareInterfaceDep, stage: Stage, logger: lt.deps.InvocationLogger
) -> DenumpifyingDict:
"""Move the microscope's stage in X and Y, to calibrate its relationship to the camera
@ -257,7 +253,7 @@ class CameraStageMapper(Thing):
return data
@thing_property
@lt.thing_property
def image_to_stage_displacement_matrix(
self,
) -> Optional[List[List[float]]]: # 2x2 integer array
@ -285,21 +281,19 @@ class CameraStageMapper(Thing):
]
return np.array(displacement_matrix).tolist()
last_calibration = ThingSetting(
last_calibration = lt.ThingSetting(
initial_value=None,
model=Optional[dict],
readonly=True,
description="The most recent CSM calibration",
)
@thing_property
@lt.thing_property
def image_resolution(self) -> Optional[Tuple[float, float]]:
"""The image size used to calibrate the image_to_stage_displacement_matrix"""
if self.last_calibration is None:
return None
return self.last_calibration["camera_stage_mapping_calibration"][
"image_resolution"
]
return self.last_calibration["image_resolution"]
def assert_calibrated(self):
"""Raise an exception if the image_to_stage_displacement matrix is not set"""
@ -308,7 +302,7 @@ class CameraStageMapper(Thing):
# added by CSMUncalibratedError
raise CSMUncalibratedError() # noqa: RSE102
@thing_action
@lt.thing_action
def move_in_image_coordinates(
self,
stage: Stage,
@ -332,7 +326,7 @@ class CameraStageMapper(Thing):
)
stage.move_relative(x=relative_move[0], y=relative_move[1])
@thing_property
@lt.thing_property
def thing_state(self) -> dict[str, Any]:
"""Summary metadata describing the current state of the Thing"""
return {

View file

@ -11,19 +11,14 @@ from socket import gethostname
from typing import Annotated, Any, MutableMapping, Optional, Sequence
from uuid import UUID, uuid4
from fastapi import Depends, HTTPException, Request
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property, thing_setting
from labthings_fastapi.descriptors import ThingSetting
from labthings_fastapi.dependencies.thing_server import find_thing_server
from labthings_fastapi.server import ThingServer
import labthings_fastapi as lt
def thing_server_from_request(request: Request) -> ThingServer:
return find_thing_server(request.app)
def thing_server_from_request(request: Request) -> lt.ThingServer:
return lt.find_thing_server(request.app)
ThingServerDep = Annotated[ThingServer, Depends(thing_server_from_request)]
ThingServerDep = Annotated[lt.ThingServer, Depends(thing_server_from_request)]
def recursive_update(old_dict: MutableMapping, update: Mapping):
@ -48,20 +43,20 @@ def nested_dict_get(data: dict, key: Sequence[str], create=False) -> Any:
return subdict
class SettingsManager(Thing):
external_metadata = ThingSetting(
class SettingsManager(lt.Thing):
external_metadata = lt.ThingSetting(
initial_value={},
model=Mapping,
description="External metadata stored in the server's settings",
)
external_metadata_in_state = ThingSetting(
external_metadata_in_state = lt.ThingSetting(
initial_value=[],
model=Sequence[str],
description='A list of strings that are included in the "state" metadata',
)
@thing_action
@lt.thing_action
def update_external_metadata(
self, data: Mapping, key: Optional[str] = None
) -> None:
@ -84,7 +79,7 @@ class SettingsManager(Thing):
recursive_update(subdict, data)
self.external_metadata = metadata
@thing_action
@lt.thing_action
def delete_external_metadata(self, key: str) -> None:
"""Delete a key from the stored metadata.
@ -104,7 +99,7 @@ class SettingsManager(Thing):
_microscope_id: Optional[str] = None
@thing_setting
@lt.thing_setting
def microscope_id(self) -> UUID:
"""A unique identifier for this microscope"""
if self._microscope_id is None:
@ -116,13 +111,13 @@ class SettingsManager(Thing):
# TODO make read only but still settable from disk
self._microscope_id = uuid
@thing_property
@lt.thing_property
def hostname(self) -> str:
"""The hostname of the microscope, as reported by its operating system."""
return gethostname()
@thing_action
def get_things_state(self, metadata_getter: GetThingStates) -> Mapping:
@lt.thing_action
def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping:
"""Metadata summarising the current state of all Things in the server"""
return metadata_getter()

View file

@ -11,21 +11,7 @@ from fastapi.responses import FileResponse
import numpy as np
from PIL import Image
from labthings_fastapi.thing import Thing
from labthings_fastapi.dependencies.metadata import GetThingStates
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
from labthings_fastapi.descriptors import ThingSetting
from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationLogger,
InvocationCancelledError,
)
from labthings_fastapi.decorators import (
thing_action,
thing_property,
fastapi_endpoint,
)
from labthings_fastapi.outputs.blob import blob_type
import labthings_fastapi as lt
from openflexure_microscope_server.utilities import ErrorCapturingThread
from openflexure_microscope_server import scan_directories
@ -38,14 +24,16 @@ from .background_detect import BackgroundDetectThing
from .camera import CameraDependency as CamDep
from .stage import StageDependency as StageDep
CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/")
AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/")
BackgroundDep = direct_thing_client_dependency(
CSMDep = lt.deps.direct_thing_client_dependency(
CameraStageMapper, "/camera_stage_mapping/"
)
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
BackgroundDep = lt.deps.direct_thing_client_dependency(
BackgroundDetectThing, "/background_detect/"
)
JPEGBlob = blob_type("image/jpeg")
ZipBlob = blob_type("application/zip")
JPEGBlob = lt.blob.blob_type("image/jpeg")
ZipBlob = lt.blob.blob_type("application/zip")
SCAN_DATA_FILENAME = "scan_data.json"
STITCHING_CMD = "openflexure-stitch"
@ -76,7 +64,7 @@ def _scan_running(method):
return scan_running_wrapper
class SmartScanThing(Thing):
class SmartScanThing(lt.Thing):
def __init__(self, scans_folder):
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
self._preview_stitch_popen = None
@ -87,17 +75,17 @@ class SmartScanThing(Thing):
self._latest_scan_name: Optional[str] = None
# Scan logger is the invocation logger labthings-fastapi creates
# when the `sample_scan` thing_action is called. It is saved as
# when the `sample_scan` lt.thing_action is called. It is saved as
# private class variable along with many others here.
# Access to these variables requires a scan to be running,
# any method that calls these should be decorrected with
# @_scan_running
self._scan_logger: Optional[InvocationLogger] = None
self._cancel: Optional[CancelHook] = None
self._scan_logger: Optional[lt.deps.InvocationLogger] = None
self._cancel: Optional[lt.deps.CancelHook] = None
self._autofocus: Optional[AutofocusDep] = None
self._stage: Optional[StageDep] = None
self._cam: Optional[CamDep] = None
self._metadata_getter: Optional[GetThingStates] = None
self._metadata_getter: Optional[lt.deps.GetThingStates] = None
self._csm: Optional[CSMDep] = None
self._background_detect: Optional[BackgroundDep] = None
self._ongoing_scan: Optional[scan_directories.ScanDirectory] = None
@ -107,15 +95,15 @@ class SmartScanThing(Thing):
# TODO Scan data is a dict during refactoring, should become a dataclass
self._scan_data: Optional[dict] = None
@thing_action
@lt.thing_action
def sample_scan(
self,
cancel: CancelHook,
logger: InvocationLogger,
cancel: lt.deps.CancelHook,
logger: lt.deps.InvocationLogger,
autofocus: AutofocusDep,
stage: StageDep,
cam: CamDep,
metadata_getter: GetThingStates,
metadata_getter: lt.deps.GetThingStates,
csm: CSMDep,
background_detect: BackgroundDep,
scan_name: str = "",
@ -210,7 +198,7 @@ class SmartScanThing(Thing):
"of motion."
)
@thing_property
@lt.thing_property
def latest_scan_name(self) -> Optional[str]:
"""The name of the last scan to be started."""
return self._latest_scan_name
@ -419,7 +407,7 @@ class SmartScanThing(Thing):
self._main_scan_loop()
self._update_scan_data_json(scan_result="success")
except InvocationCancelledError:
except lt.exceptions.InvocationCancelledError:
scan_successful = False
# Reset the cancel event so it can be thrown again
self._cancel.clear()
@ -572,7 +560,7 @@ class SmartScanThing(Thing):
except SubprocessError as e:
self._scan_logger.error(f"Stitching failed: {e}", exc_info=e)
@fastapi_endpoint(
@lt.fastapi_endpoint(
"get",
"scans/stitched_thumbnail.jpg",
responses={
@ -595,13 +583,13 @@ class SmartScanThing(Thing):
raise HTTPException(404, "File not found")
return FileResponse(preview_path)
save_resolution = ThingSetting(
save_resolution = lt.ThingSetting(
initial_value=(1640, 1232),
model=tuple[int, int],
description=("A tuple of the image resolution to capture."),
)
max_range = ThingSetting(
max_range = lt.ThingSetting(
initial_value=45000,
model=int,
description=(
@ -609,13 +597,13 @@ class SmartScanThing(Thing):
),
)
stitch_tiff = ThingSetting(
stitch_tiff = lt.ThingSetting(
initial_value=False,
model=bool,
description="Whether or not to also produce a pyramidal tiff",
)
skip_background = ThingSetting(
skip_background = lt.ThingSetting(
initial_value=True,
model=bool,
description="""Whether to detect and skip empty fields of view
@ -623,19 +611,19 @@ class SmartScanThing(Thing):
This uses the settings from the `background_detect` Thing.""",
)
autofocus_dz = ThingSetting(
autofocus_dz = lt.ThingSetting(
initial_value=1000,
model=int,
description="The z distance to perform an autofocus in steps",
)
overlap = ThingSetting(
overlap = lt.ThingSetting(
initial_value=0.45,
model=float,
description="The fraction (0-1) that adjacent images should overlap in x or y",
)
stitch_automatically = ThingSetting(
stitch_automatically = lt.ThingSetting(
initial_value=True,
model=bool,
description=(
@ -644,7 +632,7 @@ class SmartScanThing(Thing):
),
)
@thing_property
@lt.thing_property
def scans(self) -> list[scan_directories.ScanInfo]:
"""All the available scans
@ -656,7 +644,7 @@ class SmartScanThing(Thing):
"""
return self._scan_dir_manager.all_scans_info()
@fastapi_endpoint(
@lt.fastapi_endpoint(
"get",
"get_stitch/{scan_name}",
responses={
@ -680,7 +668,7 @@ class SmartScanThing(Thing):
raise HTTPException(404, "File not found")
return FileResponse(stitch_path)
@fastapi_endpoint(
@lt.fastapi_endpoint(
"delete",
"scans/{scan_name}",
responses={
@ -688,7 +676,7 @@ class SmartScanThing(Thing):
400: {"description": "An error occurred while trying to delete scan"},
},
)
def delete_scan(self, scan_name: str, logger: InvocationLogger) -> None:
def delete_scan(self, scan_name: str, logger: lt.deps.InvocationLogger) -> None:
"""Delete the folder for the specified scan.
This endpoint allows scans to be deleted from disk.
@ -702,11 +690,11 @@ class SmartScanThing(Thing):
if not deleted_scan_success:
raise HTTPException(400, "Couldn't delete scan, check log for details")
@fastapi_endpoint(
@lt.fastapi_endpoint(
"delete",
"scans",
)
def delete_all_scans(self, logger: InvocationLogger) -> None:
def delete_all_scans(self, logger: lt.deps.InvocationLogger) -> None:
"""Delete all the scans on the microscope
**This will irreversibly remove all scanned data from the
@ -716,8 +704,8 @@ class SmartScanThing(Thing):
for scan_name in self._scan_dir_manager.all_scans:
self._delete_scan(scan_name, logger)
@thing_action
def purge_empty_scans(self, logger: InvocationLogger) -> None:
@lt.thing_action
def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
"""
Delete all scan folders containing no images at the top level
"""
@ -727,7 +715,7 @@ class SmartScanThing(Thing):
if scan_info.number_of_images == 0:
self._delete_scan(scan_info.name, logger)
def _delete_scan(self, scan_name, logger: InvocationLogger) -> bool:
def _delete_scan(self, scan_name, logger: lt.deps.InvocationLogger) -> bool:
"""
A wrapper around scan manager's delete_scan that logs to the invocation logger
"""
@ -752,7 +740,7 @@ class SmartScanThing(Thing):
scan_name=self.latest_scan_name, filename="preview.jpg", check_exists=True
)
@thing_property
@lt.thing_property
def latest_preview_stitch_time(self) -> Optional[float]:
"""The modification time of the latest preview image, to allow live updating
@ -767,7 +755,7 @@ class SmartScanThing(Thing):
return None
return os.path.getmtime(self.latest_preview_stitch_path)
@fastapi_endpoint(
@lt.fastapi_endpoint(
"get",
"latest_preview_stitch.jpg",
responses={
@ -836,8 +824,8 @@ class SmartScanThing(Thing):
def run_subprocess(
self,
logger: InvocationLogger,
cancel: CancelHook,
logger: lt.deps.InvocationLogger,
cancel: lt.deps.CancelHook,
cmd: list[str],
) -> CompletedProcess:
"""
@ -873,7 +861,7 @@ class SmartScanThing(Thing):
# Note that using cancel.sleep allows the InvocationCancelledError to
# be thrown
cancel.sleep(0.2)
except InvocationCancelledError as e:
except lt.exceptions.InvocationCancelledError as e:
logger.info("Stitching cancelled by user")
process.kill()
raise e
@ -886,18 +874,18 @@ class SmartScanThing(Thing):
else:
raise ChildProcessError(f"Subprocess {cmd[0]} exited with an error.")
@thing_action
@lt.thing_action
def stitch_scan(
self,
logger: InvocationLogger,
cancel: CancelHook,
logger: lt.deps.InvocationLogger,
cancel: lt.deps.CancelHook,
scan_name: str,
stitch_resize: Optional[float] = None,
overlap: float = 0.0,
) -> None:
"""Generate a stitched image based on stage position metadata
Note that as this is a thing_action it needs the logger passed as
Note that as this is a lt.thing_action it needs the logger passed as
a variable if called from another thing action
"""
json_fpath = self._scan_dir_manager.get_file_path_from_img_dir(
@ -969,12 +957,12 @@ class SmartScanThing(Thing):
self._scan_dir_manager.img_dir_for(scan_name),
],
)
except InvocationCancelledError:
except lt.exceptions.InvocationCancelledError:
# Sleep for 1 second just to allow invocation logs to pass to user.
time.sleep(1)
pass
@thing_action
@lt.thing_action
def download_zip(
self,
scan_name: str,

View file

@ -2,14 +2,10 @@ from __future__ import annotations
from typing import TypeAlias
from collections.abc import Sequence, Mapping
from labthings_fastapi.descriptors.property import ThingProperty
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property
from labthings_fastapi.dependencies.invocation import CancelHook
from labthings_fastapi.dependencies.thing import direct_thing_client_dependency
import labthings_fastapi as lt
class BaseStage(Thing):
class BaseStage(lt.Thing):
"""A base stage class for OpenFlexure translation stages
This can't be used directly but should reduce boilerplate code when
@ -20,12 +16,12 @@ class BaseStage(Thing):
_axis_names = ("x", "y", "z")
@thing_property
@lt.thing_property
def axis_names(self) -> Sequence[str]:
"""The names of the stage's axes, in order."""
return self._axis_names
position = ThingProperty(
position = lt.ThingProperty(
Mapping[str, int],
dict.fromkeys(_axis_names, 0),
description="Current position of the stage",
@ -33,7 +29,7 @@ class BaseStage(Thing):
observable=True,
)
moving = ThingProperty(
moving = lt.ThingProperty(
bool,
False,
description="Whether the stage is in motion",
@ -46,10 +42,10 @@ class BaseStage(Thing):
"""Summary metadata describing the current state of the stage"""
return {"position": self.position}
@thing_action
@lt.thing_action
def move_relative(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
@ -58,10 +54,10 @@ class BaseStage(Thing):
"StageThings must define their own move_relative method"
)
@thing_action
@lt.thing_action
def move_absolute(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
@ -70,7 +66,7 @@ class BaseStage(Thing):
"StageThings must define their own move_absolute method"
)
@thing_action
@lt.thing_action
def set_zero_position(self):
"""Make the current position zero in all axes
@ -83,4 +79,6 @@ class BaseStage(Thing):
)
StageDependency: TypeAlias = direct_thing_client_dependency(BaseStage, "/stage/")
StageDependency: TypeAlias = lt.deps.direct_thing_client_dependency(
BaseStage, "/stage/"
)

View file

@ -1,12 +1,10 @@
from __future__ import annotations
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
from collections.abc import Mapping
import time
import labthings_fastapi as lt
from . import BaseStage
@ -27,10 +25,10 @@ class DummyStage(BaseStage):
def __exit__(self, _exc_type, _exc_value, _traceback):
pass
@thing_action
@lt.thing_action
def move_relative(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
@ -53,7 +51,7 @@ class DummyStage(BaseStage):
for k, v in zip(self.axis_names, displacement)
}
fraction_complete = 1.0
except InvocationCancelledError as e:
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,
# and to mark the invocation as "cancelled" rather than stopped.
@ -66,10 +64,10 @@ class DummyStage(BaseStage):
}
self.instantaneous_position = self.position
@thing_action
@lt.thing_action
def move_absolute(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
@ -83,7 +81,7 @@ class DummyStage(BaseStage):
cancel, block_cancellation=block_cancellation, **displacement
)
@thing_action
@lt.thing_action
def set_zero_position(self):
"""Make the current position zero in all axes

View file

@ -7,11 +7,7 @@ from contextlib import contextmanager
from collections.abc import Mapping
import sangaboard
from labthings_fastapi.decorators import thing_action
from labthings_fastapi.dependencies.invocation import (
CancelHook,
InvocationCancelledError,
)
import labthings_fastapi as lt
from . import BaseStage
@ -57,10 +53,10 @@ class SangaboardThing(BaseStage):
with self.sangaboard() as sb:
self.position = dict(zip(self.axis_names, sb.position))
@thing_action
@lt.thing_action
def move_relative(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
) -> None:
@ -75,7 +71,7 @@ class SangaboardThing(BaseStage):
else:
while sb.query("moving?") == "true":
cancel.sleep(0.1)
except InvocationCancelledError as e:
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,
# and to mark the invocation as "cancelled" rather than stopped.
@ -85,10 +81,10 @@ class SangaboardThing(BaseStage):
self.moving = False
self.update_position()
@thing_action
@lt.thing_action
def move_absolute(
self,
cancel: CancelHook,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
) -> None:
@ -104,7 +100,7 @@ class SangaboardThing(BaseStage):
cancel, block_cancellation=block_cancellation, **displacement
)
@thing_action
@lt.thing_action
def set_zero_position(self) -> None:
"""Make the current position zero in all axes
@ -116,7 +112,7 @@ class SangaboardThing(BaseStage):
sb.zero_position()
self.update_position()
@thing_action
@lt.thing_action
def flash_led(
self,
number_of_flashes: int = 10,

View file

@ -6,8 +6,7 @@ This module defines a Thing that can shut down or restart the host computer.
import subprocess
import os
from labthings_fastapi.thing import Thing
from labthings_fastapi.decorators import thing_action, thing_property
import labthings_fastapi as lt
from pydantic import BaseModel
@ -16,12 +15,12 @@ class CommandOutput(BaseModel):
error: str
class SystemControlThing(Thing):
class SystemControlThing(lt.Thing):
"""
Attempt to shutdown the device
"""
@thing_action
@lt.thing_action
def shutdown(self) -> CommandOutput:
"""
Attempt to shutdown the device
@ -35,14 +34,14 @@ class SystemControlThing(Thing):
out, err = p.communicate()
return CommandOutput(output=out, error=err)
@thing_property
@lt.thing_property
def is_raspberrypi() -> bool:
"""
Checks if we are running on a Raspberry Pi.
"""
return os.path.exists("/usr/bin/raspi-config")
@thing_action
@lt.thing_action
def reboot(self) -> CommandOutput:
"""Attempt to reboot the device"""
p = subprocess.Popen(