From adbb805556091998e1e7ec7e75577ec4c5837367 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 29 Nov 2024 11:26:38 +0000 Subject: [PATCH 1/5] Formal Protocol for camera interface I've now split the base camera code into a runtime-checkable Protocol and a concrete base class (which is optional). This removes the requirement for cameras to subclass `openflexure_microscope.things.camera.Camera` and instead uses duck typing that should work both statically and dynamically. As part of this, I've moved the commonly-used dependencies on the camera inside `openflexure_microscope.things.camera` for consistency, and to make it easy to update them if it changes in the future. --- .../things/auto_recentre_stage.py | 2 - .../things/autofocus.py | 5 +- .../things/camera/__init__.py | 123 ++++++++++-------- .../things/camera/opencv.py | 4 +- .../things/camera/simulation.py | 4 +- .../things/camera_stage_mapping.py | 3 +- .../things/smart_scan.py | 3 +- 7 files changed, 76 insertions(+), 68 deletions(-) diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index a36feae2..1a22c9d4 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -5,12 +5,10 @@ from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.decorators import thing_action from .stage import Stage -from .camera import Camera from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper StageDep = direct_thing_client_dependency(Stage, "/stage/") -CamDep = direct_thing_client_dependency(Camera, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index c010a3f6..4250ff7d 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -19,14 +19,13 @@ from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.decorators import thing_action from labthings_fastapi.types.numpy import NDArray -from .camera import Camera as CameraThing +from .camera import RawCameraDependency as Camera +from .camera import CameraDependency as WrappedCamera from .stage import Stage as StageThing import numpy as np from pydantic import BaseModel Stage = direct_thing_client_dependency(StageThing, "/stage/") -Camera = raw_thing_dependency(CameraThing) -WrappedCamera = direct_thing_client_dependency(CameraThing, "/camera/") ### Autofocus utilities diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 43d4694d..4af5cceb 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -7,54 +7,89 @@ See repository root for licensing information. """ from __future__ import annotations import logging -from typing import Literal +from typing import Literal, Protocol, TypeAlias, runtime_checkable from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action 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.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.outputs.blob import blob_type from labthings_fastapi.types.numpy import NDArray -JPEGBlob = blob_type("image/jpeg") +JPEGBlob: TypeAlias = blob_type("image/jpeg") -class CameraMeta: - def __subclasscheck__(cls, C): - """Override Python's default behaviour and use duck typing""" - print(f"Checking if {C} is a camera...") - for action in ["snap_image", "capture_array", "capture_jpeg", "grab_jpeg", "grab_jpeg_size"]: - a = getattr(C, action, None) - if not callable(a): - return False - for prop in ["stream_active"]: - if not hasattr(C, prop): - return False - for k in ["mjpeg_stream", "lores_mjpeg_stream"]: - stream = getattr(C, k) - if not isinstance(stream, MJPEGStreamDescriptor): - return False - return True - - def __instancecheck__(cls, I): - return cls.__subclasscheck__(I) - - -class Camera(Thing): - __metaclass__ = CameraMeta +@runtime_checkable +class CameraProtocol(Protocol): """A Thing representing a camera""" + def __enter__(self) -> None: + ... + + def __exit__(self, _exc_type, _exc_value, _traceback) -> None: + ... + + @property + def stream_active(self) -> bool: + "Whether the MJPEG stream is active" + ... + + def snap_image(self) -> NDArray: + """Acquire one image from the camera.""" + ... + + def capture_array( + self, + resolution: Literal["lores", "main", "full"] = "main", + ) -> NDArray: + ... + + def capture_jpeg( + self, + metadata_getter: GetThingStates, + resolution: Literal["lores", "main", "full"] = "main", + ) -> JPEGBlob: + """Acquire one image from the camera and return as a JPEG blob""" + ... + + def grab_jpeg( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> JPEGBlob: + """Acquire one image from the preview stream and return as an array + + This differs from `capture_jpeg` in that it does not pause the MJPEG + preview stream. Instead, we simply return the next frame from that + stream (either "main" for the preview stream, or "lores" for the low + resolution preview). No metadata is returned. + """ + ... + + def grab_jpeg_size( + self, + portal: BlockingPortal, + stream_name: Literal["main", "lores"] = "main", + ) -> int: + """Acquire one image from the preview stream and return its size""" + ... + +class BaseCamera(Thing): + """A Thing representing a camera + + This is a concrete base class for `Thing`s implementing the `CameraProtocol`. + It provides the stream descriptors and actions to grab from the stream. + """ + def __enter__(self): raise NotImplementedError("Subclasses must implement __enter__") def __exit__(self, _exc_type, _exc_value, _traceback): raise NotImplementedError("Subclasses must implement __exit__") - @thing_property - def stream_active(self) -> bool: - "Whether the MJPEG stream is active" - raise NotImplementedError("Subclasses must implement stream_active") mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() @@ -67,31 +102,6 @@ class Camera(Thing): """ return self.capture_array() - @thing_action - def capture_array( - self, - resolution: Literal["lores", "main", "full"] = "main", - ) -> NDArray: - """Acquire one image from the camera and return as an array - - This function will produce a nested list containing an uncompressed RGB image. - It's likely to be highly inefficient - raw and/or uncompressed captures using - binary image formats will be added in due course. - """ - raise NotImplementedError("Subclasses must implement capture_array") - - @thing_action - def capture_jpeg( - self, - metadata_getter: GetThingStates, - resolution: Literal["lores", "main", "full"] = "main", - ) -> JPEGBlob: - """Acquire one image from the camera and return as a JPEG blob - - This function will produce a JPEG image. - """ - raise NotImplementedError("Subclasses must implement capture_jpeg") - @thing_action def grab_jpeg( self, @@ -125,3 +135,6 @@ class Camera(Thing): ) return portal.call(stream.next_frame_size) + +CameraDependency = direct_thing_client_dependency(CameraProtocol, "/camera/") +RawCameraDependency = raw_thing_dependency(CameraProtocol) diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 7285d35c..c8d2b282 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -21,10 +21,10 @@ from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.types.numpy import NDArray -from . import Camera, JPEGBlob +from . import BaseCamera, JPEGBlob -class OpenCVCamera(Camera): +class OpenCVCamera(BaseCamera): """A Thing representing an OpenCV camera""" def __init__(self, camera_index: int=0): self.camera_index = camera_index diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a431c867..9e764df7 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -24,11 +24,11 @@ from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.server import ThingServer -from . import Camera, JPEGBlob +from . import BaseCamera, JPEGBlob from ..stage import Stage -class SimulatedCamera(Camera): +class SimulatedCamera(BaseCamera): """A Thing representing an OpenCV camera""" shape = (600, 800, 3) glyph_shape = (51, 51, 3) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 395c9e6d..cabc26a1 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -22,7 +22,7 @@ from camera_stage_mapping.camera_stage_calibration_1d import ( image_to_stage_displacement_from_1d, ) from camera_stage_mapping.camera_stage_tracker import Tracker -from .camera import Camera as CameraThing +from .camera import CameraDependency as Camera from .stage import Stage as StageThing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency @@ -31,7 +31,6 @@ from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.thing import Thing -Camera = direct_thing_client_dependency(CameraThing, "/camera/") Stage = direct_thing_client_dependency(StageThing, "/stage/") CoordinateType = Tuple[float, float, float] diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index a05372d3..7a9c60af 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -28,14 +28,13 @@ from labthings_fastapi.dependencies.thing import direct_thing_client_dependency 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 -from .camera import Camera +from .camera import CameraDependency as CamDep from .stage import Stage from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.auto_recentre_stage import RecentringThing -CamDep = direct_thing_client_dependency(Camera, "/camera/") StageDep = direct_thing_client_dependency(Stage, "/stage/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") From 2e2e877d75b60ac721a9641cb21806dba4dd5f44 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 29 Nov 2024 12:19:05 +0000 Subject: [PATCH 2/5] JPEGBlob class definition Static type checking is easier if JPEGBlob is declared as a class, rather than being created with a function. --- .../things/camera/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 4af5cceb..156af6ca 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -7,7 +7,7 @@ See repository root for licensing information. """ from __future__ import annotations import logging -from typing import Literal, Protocol, TypeAlias, runtime_checkable +from typing import Literal, Protocol, runtime_checkable from labthings_fastapi.thing import Thing from labthings_fastapi.decorators import thing_action @@ -16,11 +16,12 @@ 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.outputs.mjpeg_stream import MJPEGStreamDescriptor -from labthings_fastapi.outputs.blob import blob_type +from labthings_fastapi.outputs.blob import Blob from labthings_fastapi.types.numpy import NDArray -JPEGBlob: TypeAlias = blob_type("image/jpeg") +class JPEGBlob(Blob): + media_type: str = "image/jpeg" @runtime_checkable class CameraProtocol(Protocol): From a0fbbc3e4063b843106adf9d11ed7c45eb7862e8 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 29 Nov 2024 12:53:04 +0000 Subject: [PATCH 3/5] Convert Stage to a Protocol The Stage is now implemented as a runtime-checkable Protocol. Part of this led to the realisation that we can't create a DirectThingClient based on a Protocol, only on a concrete Thing. While it might be a good idea to address that in LabThings in due course, for now we work around it by creating both a Protocol and a stub class. The stub class implements the protocol, and can be used to declare a dependency. --- .../things/auto_recentre_stage.py | 3 +- .../things/autofocus.py | 3 +- .../things/camera/simulation.py | 2 +- .../things/camera_stage_mapping.py | 10 +-- .../things/smart_scan.py | 3 +- .../things/stage/__init__.py | 77 ++++++++++++++++--- .../things/stage/dummy.py | 4 +- 7 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index 1a22c9d4..f0dfcf74 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -4,11 +4,10 @@ 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 -from .stage import Stage +from .stage import StageDependency as StageDep from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper -StageDep = direct_thing_client_dependency(Stage, "/stage/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 4250ff7d..9f2dae2f 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -21,11 +21,10 @@ from labthings_fastapi.decorators import thing_action from labthings_fastapi.types.numpy import NDArray from .camera import RawCameraDependency as Camera from .camera import CameraDependency as WrappedCamera -from .stage import Stage as StageThing +from .stage import StageDependency as Stage import numpy as np from pydantic import BaseModel -Stage = direct_thing_client_dependency(StageThing, "/stage/") ### Autofocus utilities diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 9e764df7..dd169283 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -25,7 +25,7 @@ from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.server import ThingServer from . import BaseCamera, JPEGBlob -from ..stage import Stage +from ..stage import StageProtocol as Stage class SimulatedCamera(BaseCamera): diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index cabc26a1..72415336 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -21,17 +21,13 @@ from camera_stage_mapping.camera_stage_calibration_1d import ( calibrate_backlash_1d, image_to_stage_displacement_from_1d, ) -from camera_stage_mapping.camera_stage_tracker import Tracker -from .camera import CameraDependency as Camera -from .stage import Stage as StageThing - -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.thing import Thing - -Stage = direct_thing_client_dependency(StageThing, "/stage/") +from camera_stage_mapping.camera_stage_tracker import Tracker +from .camera import CameraDependency as Camera +from .stage import StageDependency as Stage CoordinateType = Tuple[float, float, float] XYCoordinateType = Tuple[float, float] diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 7a9c60af..51a445eb 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -29,13 +29,12 @@ from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogg from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint from labthings_fastapi.outputs.blob import blob_type from .camera import CameraDependency as CamDep -from .stage import Stage +from .stage import StageDependency as StageDep from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.auto_recentre_stage import RecentringThing -StageDep = direct_thing_client_dependency(Stage, "/stage/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") RecentreStage = direct_thing_client_dependency(RecentringThing, "/auto_recentre_stage/") diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index f600f58e..36d11a8b 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -1,16 +1,63 @@ from __future__ import annotations +from typing import Any, Protocol, TypeAlias, runtime_checkable from labthings_fastapi.descriptors.property import PropertyDescriptor 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 from collections.abc import Sequence, Mapping +@runtime_checkable +class StageProtocol(Protocol): + """A protocol for the OpenFlexure translation stage + """ + _axis_names: Sequence[str] -class Stage(Thing): - """A dummy stage for testing purposes + @property + def axis_names(self) -> Sequence[str]: + """The names of the stage's axes, in order.""" + ... + + @property + def position(self) -> Mapping[str, int]: + """Current position of the stage""" + ... + + @property + def moving() -> bool: + "Whether the stage is in motion" + ... + + @property + def thing_state(self) -> Mapping[str, Any]: + """Summary metadata describing the current state of the stage""" + ... - This stage should work similarly to a Sangaboard stage, but without any - hardware attached. + def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + """Make a relative move. Keyword arguments should be axis names.""" + ... + + def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + """Make an absolute move. Keyword arguments should be axis names.""" + ... + + def set_zero_position(self): + """Make the current position zero in all axes + + This action does not move the stage, but resets the position to zero. + It is intended for use after manually or automatically recentring the + stage. + """ + ... + + +class BaseStage(Thing): + """A base stage class for OpenFlexure translation stages + + This can't be used directly but should reduce boilerplate code when + implementing new stages. A minimal working stage must implement + `move_relative` and `move_absolute` actions, which update the + `position` property on completion, and provide `set_zero_position`. """ _axis_names = ("x", "y", "z") @@ -41,17 +88,26 @@ class Stage(Thing): return { "position": self.position } - + + +class StageStub(BaseStage): + """A Stub Thing for the OpenFlexure translation stage + + As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency + based on a protocol, because the protocol is not a Thing, and its + methods/properties are not decorated as Affordances. This stub class + is a workaround for that limitation, and should not be used directly. + """ @thing_action def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): """Make a relative move. Keyword arguments should be axis names.""" - raise NotImplementedError("Subclasses should implement this method") + raise NotImplementedError("StageStub should not be used directly") @thing_action def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): """Make an absolute move. Keyword arguments should be axis names.""" - raise NotImplementedError("Subclasses should implement this method") - + raise NotImplementedError("StageStub should not be used directly") + @thing_action def set_zero_position(self): """Make the current position zero in all axes @@ -60,4 +116,7 @@ class Stage(Thing): It is intended for use after manually or automatically recentring the stage. """ - raise NotImplementedError("Subclasses should implement this method") + raise NotImplementedError("StageStub should not be used directly") + + +StageDependency: TypeAlias = direct_thing_client_dependency(StageStub, "/stage/") diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 6e984470..907ebb16 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -4,10 +4,10 @@ from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCanc from collections.abc import Mapping import time -from . import Stage +from . import BaseStage -class DummyStage(Stage): +class DummyStage(BaseStage): """A dummy stage for testing purposes This stage should work similarly to a Sangaboard stage, but without any From bccb1ee923f2dfe9539c039473f5a06a1d9742f0 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 29 Nov 2024 12:53:42 +0000 Subject: [PATCH 4/5] Stub class for camera protocol As with the previous commit, this adds a concrete stub Thing to enable DirectThingClient generation, so we can depend on the camera. --- .../things/camera/__init__.py | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 156af6ca..62dc0709 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -10,7 +10,7 @@ import logging from typing import Literal, Protocol, runtime_checkable from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action +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 @@ -85,12 +85,6 @@ class BaseCamera(Thing): It provides the stream descriptors and actions to grab from the stream. """ - def __enter__(self): - raise NotImplementedError("Subclasses must implement __enter__") - - def __exit__(self, _exc_type, _exc_value, _traceback): - raise NotImplementedError("Subclasses must implement __exit__") - mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() @@ -135,7 +129,51 @@ class BaseCamera(Thing): self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream ) return portal.call(stream.next_frame_size) + + +class CameraStub(BaseCamera): + """A stub for a camera, to allow dependencies + + + As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency + based on a protocol, because the protocol is not a Thing, and its + methods/properties are not decorated as Affordances. This stub class + is a workaround for that limitation, and should not be used directly. + + This stub class should be used for dependencies on the CameraProtocol. + """ + def __enter__(self) -> None: + raise NotImplementedError("Cameras must not inherit from CameraStub") + + def __exit__(self, _exc_type, _exc_value, _traceback) -> None: + raise NotImplementedError("Cameras must not inherit from CameraStub") + + @thing_property + def stream_active(self) -> bool: + "Whether the MJPEG stream is active" + raise NotImplementedError("Cameras must not inherit from CameraStub") + + @thing_action + def snap_image(self) -> NDArray: + """Acquire one image from the camera.""" + raise NotImplementedError("Cameras must not inherit from CameraStub") + + @thing_action + def capture_array( + self, + resolution: Literal["lores", "main", "full"] = "main", + ) -> NDArray: + raise NotImplementedError("Cameras must not inherit from CameraStub") + + @thing_action + def capture_jpeg( + self, + metadata_getter: GetThingStates, + resolution: Literal["lores", "main", "full"] = "main", + ) -> JPEGBlob: + """Acquire one image from the camera and return as a JPEG blob""" + raise NotImplementedError("Cameras must not inherit from CameraStub") -CameraDependency = direct_thing_client_dependency(CameraProtocol, "/camera/") +CameraDependency = direct_thing_client_dependency(CameraStub, "/camera/") RawCameraDependency = raw_thing_dependency(CameraProtocol) From 58066f1948b1fa7233a27625e0cbfa59b8413a1b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Fri, 29 Nov 2024 13:03:36 +0000 Subject: [PATCH 5/5] Add a first unit test This checks that the server can start and perform an autofocus. Autofocus in turn will test the camera and stage dependencies. --- tests/images/capture_v280.jpg | Bin 906 -> 0 bytes tests/test_api_utilities.py | 19 - tests/test_app.py | 43 -- tests/test_camera_framestream.py | 74 --- tests/test_capture_manager.py | 150 ----- tests/test_capture_object.py | 232 ------- tests/test_dummy_server.py | 22 + tests/test_extension_scan.py | 57 -- tests/test_json.py | 59 -- tests/test_utilities.py | 31 - tests/w3c_td_schema.json | 1035 ------------------------------ 11 files changed, 22 insertions(+), 1700 deletions(-) delete mode 100644 tests/images/capture_v280.jpg delete mode 100644 tests/test_api_utilities.py delete mode 100644 tests/test_app.py delete mode 100644 tests/test_camera_framestream.py delete mode 100644 tests/test_capture_manager.py delete mode 100644 tests/test_capture_object.py create mode 100644 tests/test_dummy_server.py delete mode 100644 tests/test_extension_scan.py delete mode 100644 tests/test_json.py delete mode 100644 tests/test_utilities.py delete mode 100644 tests/w3c_td_schema.json diff --git a/tests/images/capture_v280.jpg b/tests/images/capture_v280.jpg deleted file mode 100644 index a2d4d1a188962c8bb37a2ee0e3efe71f8fdaa25e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 906 zcmex=GzJD=Uj{7(1_llW#`a7G76t|eMg|53DFzT=oYcm^&cML%oPmKs zqgp95H!(d`$x5MGDKkaMNIzCa$}%z<=9)SNh9)K^^6Hkh7DhID#wJD}Ll`+ZIeEBw zBzSoxj1*-QjYtOn4=@OFFo-aSFf%GKFbOg;3o`yc!XVGUz{tu72B0VcVMZoq7FITP z4o)ua|3?_M3NSD+GBY!=Ftf6ovIz$!vMUve7&T5@$f4}C z@t|nX#SbdRNkvVZTw>x9l2WQ_>Kd9_CZ=ZQ7M51dF0O9w9-dyoA)#U65s^{JDXD4c z8JStdC8cHM6_r)ZEv;?s9i3g1CQq3GGAU*RJ2VdF$b$$4{OPfBE|D`;VW$K>lK6U=!0ld z(M2vX6_bamA3 0: - assert f.frames[i].time > f.frames[i - 1].time - - # Test FrameStream.last - assert f.last.size == sizes[-1] + 2 - - -def test_new_frame_event(): - d = b"openflexure" - - def target(framestream): - time.sleep(0.01) - framestream.write(d) - - with FrameStream() as f: - # Start a thread to write some data - thread = threading.Thread(target=target, args=(f,)) - thread.daemon = True - thread.start() - # Make sure the stream is empty before we wait for a frame - assert not f.getvalue() - # Wait for a frame to be written - frame = f.getframe() - assert frame == d - # Make sure getframe() cleared the event - assert not f.new_frame.events[threading.get_ident()][0].is_set() diff --git a/tests/test_capture_manager.py b/tests/test_capture_manager.py deleted file mode 100644 index 16fb768b..00000000 --- a/tests/test_capture_manager.py +++ /dev/null @@ -1,150 +0,0 @@ -import os - -import pytest -from freezegun import freeze_time - -from openflexure_microscope.captures.capture_manager import ( - BASE_CAPTURE_PATH, - TEMP_CAPTURE_PATH, - CaptureManager, -) - - -def test_new_manager(): - m = CaptureManager() - assert BASE_CAPTURE_PATH - assert TEMP_CAPTURE_PATH - assert m.paths["default"] == BASE_CAPTURE_PATH - assert m.paths["temp"] == TEMP_CAPTURE_PATH - - -def test_update_settings(): - m = CaptureManager() - assert m.paths["default"] == BASE_CAPTURE_PATH - assert m.paths["temp"] == TEMP_CAPTURE_PATH - - new_settings = { - "paths": {"default": "BASE_CAPTURE_PATH", "temp": "TEMP_CAPTURE_PATH"} - } - m.update_settings(new_settings) - assert m.paths["default"] == "BASE_CAPTURE_PATH" - assert m.paths["temp"] == "TEMP_CAPTURE_PATH" - assert m.read_settings() == new_settings - - -def test__new_output(): - m = CaptureManager() - - out = m._new_output(True, "filename", "subfolder", "fmt") - assert out.file == os.path.join(TEMP_CAPTURE_PATH, "subfolder", "filename.fmt") - - -def test__new_output_generate_filename(): - m = CaptureManager() - - out = m._new_output(True, None, "subfolder", "fmt") - # Assert a filename was actually generated - assert out.name != ".fmt" - - -def test_new_image(): - m = CaptureManager() - out = m.new_image() - capture_key = str(out.id) - assert capture_key in m.images.keys() - assert m.images[capture_key] is out - - -def test_new_video(): - m = CaptureManager() - out = m.new_video() - capture_key = str(out.id) - assert capture_key in m.videos.keys() - assert m.videos[capture_key] is out - - -def test_image_from_id_str(): - m = CaptureManager() - out = m.new_image() - capture_key = str(out.id) - - assert m.image_from_id(capture_key) is out - - -def test_image_from_id_int(): - m = CaptureManager() - out = m.new_image() - capture_key = int(out.id) - - assert m.image_from_id(capture_key) is out - - -def test_image_from_id_uuid(): - m = CaptureManager() - out = m.new_image() - capture_key = out.id - - assert m.image_from_id(capture_key) is out - - -def test_image_from_id_invalid(): - m = CaptureManager() - out = m.new_image() - capture_key = object() - - with pytest.raises(TypeError): - m.image_from_id(capture_key) is out - - -def test_video_from_id_str(): - m = CaptureManager() - out = m.new_video() - capture_key = str(out.id) - - assert m.video_from_id(capture_key) is out - - -def test_video_from_id_int(): - m = CaptureManager() - out = m.new_video() - capture_key = int(out.id) - - assert m.video_from_id(capture_key) is out - - -def test_video_from_id_uuid(): - m = CaptureManager() - out = m.new_video() - capture_key = out.id - - assert m.video_from_id(capture_key) is out - - -def test_video_from_id_invalid(): - m = CaptureManager() - out = m.new_video() - capture_key = object() - - with pytest.raises(TypeError): - m.video_from_id(capture_key) is out - - -@freeze_time("2020-11-27 12:00:01") -def test_generate_numbered_basename(): - m = CaptureManager() - for _ in range(10): - m.new_image() - generated_filenames = [obj.basename for obj in m.images.values()] - # Assert enough names were generated - assert len(generated_filenames) == 10 - # Assert all names are unique - len(generated_filenames) > len(set(generated_filenames)) - # Assert all filenames are in the expected form - for fname in generated_filenames: - assert fname.startswith("2020-11-27_12-00-01") - - -def test_context_manager(): - with CaptureManager() as m: - m.new_image() - m.new_video() diff --git a/tests/test_capture_object.py b/tests/test_capture_object.py deleted file mode 100644 index 12bcb99d..00000000 --- a/tests/test_capture_object.py +++ /dev/null @@ -1,232 +0,0 @@ -import datetime -import io -import os -import uuid -from collections import OrderedDict - -import piexif -from PIL import Image, ImageChops - -from openflexure_microscope.captures import THUMBNAIL_SIZE, CaptureObject -from openflexure_microscope.captures.capture import ( - build_captures_from_exif, - capture_from_path, -) - -IN_DIR = "tests/images/" -OUT_DIR = "tests/images/out/" - - -def generate_small_image(): - # Create a dummy image to serve in the stream - image = Image.new("RGB", (20, 20), color=(0, 0, 0)) - - return image - - -def generate_small_thumbnail(): - # Create a dummy image to serve in the stream - image = Image.new("RGB", (20, 20), color=(0, 0, 0)) - image.thumbnail(THUMBNAIL_SIZE) - - return image - - -def generate_small_capture(filename: str): - out = os.path.join(OUT_DIR, filename) - obj = CaptureObject(out) - generate_small_image().save(obj, format="JPEG") - - return obj - - -def check_valid_capture(obj: CaptureObject): - assert isinstance(obj.time, datetime.datetime) - assert isinstance(obj.id, uuid.UUID) - - assert isinstance(obj.annotations, dict) - assert isinstance(obj.tags, list) - - assert obj.file - assert obj.format - assert obj.basename - assert obj.filefolder - assert obj.name - - -def check_valid_openflexure_metadata(d: dict): - assert "image" in d - assert d["image"].get("id") - assert d["image"].get("name") - assert d["image"].get("time") - assert d["image"].get("format") - - assert isinstance(d["image"].get("tags"), list) - assert isinstance(d["image"].get("annotations"), dict) - - -def test_new_capture_object(): - obj = generate_small_capture("capture0.jpg") - check_valid_capture(obj) - - -def test_initial_metadata(): - obj = generate_small_capture("capture1.jpg") - - data = obj.read_full_metadata() - check_valid_openflexure_metadata(data) - - -def test_tags(): - obj = generate_small_capture("capture2.jpg") - - obj.put_tags(["foo", "bar"]) - assert obj.read_full_metadata()["image"]["tags"] == ["foo", "bar"] - - obj.delete_tag("foo") - assert obj.read_full_metadata()["image"]["tags"] == ["bar"] - - # Delete nonexistent tag, should change nothing - obj.delete_tag("foo") - assert obj.read_full_metadata()["image"]["tags"] == ["bar"] - - -def test_annotations(): - obj = generate_small_capture("capture3.jpg") - - obj.put_annotations({"foo": "zoo", "bar": "zar"}) - assert obj.read_full_metadata()["image"]["annotations"] == { - "foo": "zoo", - "bar": "zar", - } - - obj.delete_annotation("foo") - assert obj.read_full_metadata()["image"]["annotations"] == {"bar": "zar"} - - # Delete nonexistent annotation, should change nothing - obj.delete_annotation("foo") - assert obj.read_full_metadata()["image"]["annotations"] == {"bar": "zar"} - - -def test_put_metadata(): - obj = generate_small_capture("capture4.jpg") - - obj.put_metadata({"foo": {"bar": "zar"}}) - assert obj.read_full_metadata()["foo"] == {"bar": "zar"} - - -def test_put_and_save(): - obj = generate_small_capture("capture_all.jpg") - obj.put_and_save( - tags=["foo", "bar"], - annotations={"foo": "zoo", "bar": "zar"}, - metadata={"foo": {"bar": "zar"}}, - ) - - full_metadata = obj.read_full_metadata() - assert full_metadata["image"]["tags"] == ["foo", "bar"] - assert full_metadata["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} - assert full_metadata["foo"] == {"bar": "zar"} - - -def test_capture_from_path_this_version(): - """Tests reloading a capture object from a file created in the working server version - """ - - def _check_metadata(data: dict): - assert data["image"]["tags"] == ["foo", "bar"] - assert data["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} - assert data["foo"] == {"bar": "zar"} - - # Create the capture file - obj_in = generate_small_capture("capture_reload.jpg") - obj_in.put_and_save( - tags=["foo", "bar"], - annotations={"foo": "zoo", "bar": "zar"}, - metadata={"foo": {"bar": "zar"}}, - ) - - full_metadata_in = obj_in.read_full_metadata() - check_valid_openflexure_metadata(full_metadata_in) - _check_metadata(full_metadata_in) - - # Reload the capture file - obj = capture_from_path(os.path.join(OUT_DIR, "capture_reload.jpg")) - check_valid_capture(obj) - full_metadata = obj.read_full_metadata() - check_valid_openflexure_metadata(full_metadata) - _check_metadata(full_metadata) - - -def test_capture_from_path_v280(): - """Tests reloading a capture object from a file created in server v2.8.0 - """ - obj = capture_from_path(os.path.join(IN_DIR, "capture_v280.jpg")) - check_valid_capture(obj) - full_metadata = obj.read_full_metadata() - check_valid_openflexure_metadata(full_metadata) - assert full_metadata["image"]["tags"] == ["foo", "bar"] - assert full_metadata["image"]["annotations"] == {"foo": "zoo", "bar": "zar"} - assert full_metadata["foo"] == {"bar": "zar"} - - -def test_build_captures_from_exif(): - # Load the whole tests captures directory - objs = build_captures_from_exif(IN_DIR) - # Make sure we have a valid, populated OrderedDict - assert isinstance(objs, OrderedDict) - assert len(objs) > 0 - # Check each reloaded capture - for obj in objs.values(): - check_valid_capture(obj) - - -def test_data(): - # Get a reference PIL image - ref_data = generate_small_image() - # Generate a capture with the same image data as our reference - obj = generate_small_capture("capture5.jpg") - # Pull the capture data out - obj_data = Image.open(obj.data) - # Compare the images - diff = ImageChops.difference(obj_data, ref_data) - assert not diff.getbbox() - - -def test_binary(): - # Get a reference PIL image - ref_data = generate_small_image() - # Generate a capture with the same image data as our reference - obj = generate_small_capture("capture6.jpg") - # Pull the capture data out - obj_data = Image.open(io.BytesIO(obj.binary)) - # Compare the images - diff = ImageChops.difference(obj_data, ref_data) - assert not diff.getbbox() - - -def test_thumbnail(): - # Get a reference PIL image - ref_thumb = generate_small_thumbnail() - # Generate a capture with the same image data as our reference - obj = generate_small_capture("capture7.jpg") - # Pull the generated thumbnail out - obj_thumb = Image.open(obj.thumbnail) - # Compare the images - diff = ImageChops.difference(obj_thumb, ref_thumb) - assert not diff.getbbox() - - # Assert thumbnail was saved to capture file EXIF data - exif_dict = piexif.load(obj.file) - thumbnail = exif_dict.pop("thumbnail") - assert thumbnail - # Compare the images - diff = ImageChops.difference(Image.open(io.BytesIO(thumbnail)), ref_thumb) - - -def test_delete(): - # Generate a capture with the same image data as our reference - obj = generate_small_capture("capture_del.jpg") - assert os.path.isfile(obj.file) - obj.delete() - assert not os.path.isfile(obj.file) diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py new file mode 100644 index 00000000..db7fd89e --- /dev/null +++ b/tests/test_dummy_server.py @@ -0,0 +1,22 @@ +import tempfile + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from labthings_fastapi.client import ThingClient + +from openflexure_microscope_server.server import ThingServer +from openflexure_microscope_server.things.camera.simulation import SimulatedCamera +from openflexure_microscope_server.things.stage.dummy import DummyStage +from openflexure_microscope_server.things.autofocus import AutofocusThing + +temp_folder = tempfile.TemporaryDirectory() +server = ThingServer(temp_folder.name) +server.add_thing(SimulatedCamera(), "/camera/") +server.add_thing(DummyStage(), "/stage/") +server.add_thing(AutofocusThing(), "/autofocus/") + +def test_autofocus(): + with TestClient(server.app) as client: + autofocus = ThingClient.from_url("/autofocus/", client) + _ = autofocus.fast_autofocus() diff --git a/tests/test_extension_scan.py b/tests/test_extension_scan.py deleted file mode 100644 index 924e7ea5..00000000 --- a/tests/test_extension_scan.py +++ /dev/null @@ -1,57 +0,0 @@ -from openflexure_microscope.api.default_extensions import scan - - -def test_construct_grid_raster(): - grid = scan.construct_grid((0, 0), (100, 100), (3, 3), style="raster") - assert grid == [ - [(0, 0), (0, 100), (0, 200)], - [(100, 0), (100, 100), (100, 200)], - [(200, 0), (200, 100), (200, 200)], - ] - - -def test_construct_grid_snake(): - grid = scan.construct_grid((0, 0), (100, 100), (3, 3), style="snake") - assert grid == [ - [(0, 0), (0, 100), (0, 200)], - [(100, 200), (100, 100), (100, 0)], - [(200, 0), (200, 100), (200, 200)], - ] - - -def test_construct_grid_spiral(): - grid = scan.construct_grid((0, 0), (100, 100), (3, 0), style="spiral") - assert grid == [ - [(0, 0)], - [ - (0, 100), - (100, 100), - (100, 0), - (100, -100), - (0, -100), - (-100, -100), - (-100, 0), - (-100, 100), - ], - [ - (-100, 200), - (0, 200), - (100, 200), - (200, 200), - (200, 100), - (200, 0), - (200, -100), - (200, -200), - (100, -200), - (0, -200), - (-100, -200), - (-200, -200), - (-200, -100), - (-200, 0), - (-200, 100), - (-200, 200), - ], - ] - - # Ensure second n_steps value makes no difference - assert grid == scan.construct_grid((0, 0), (100, 100), (3, 3), style="spiral") diff --git a/tests/test_json.py b/tests/test_json.py deleted file mode 100644 index 0bd53f9f..00000000 --- a/tests/test_json.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import uuid -from fractions import Fraction - -import numpy as np -import pytest - -from openflexure_microscope.json import JSONEncoder - - -@pytest.mark.parametrize( - "test_input,expected", - [ - (uuid.uuid1(), 1), - (uuid.uuid3(uuid.NAMESPACE_DNS, "openflexure.org"), 3), - (uuid.uuid4(), 4), - (uuid.uuid5(uuid.NAMESPACE_DNS, "openflexure.org"), 5), - ], -) -def test_encode_uuid(test_input, expected): - encoded = json.dumps(test_input, cls=JSONEncoder) - decoded = json.loads(encoded) - assert uuid.UUID(decoded).version == expected - - -@pytest.mark.parametrize( - "test_input", - [ - Fraction(1, 2), - Fraction(1, 3), - Fraction(-27, 4), - Fraction(27, -4), - Fraction(10), - Fraction(18, 63), - Fraction(1, 1000000), - ], -) -def test_encode_fraction(test_input): - encoded = json.dumps(test_input, cls=JSONEncoder) - decoded = json.loads(encoded) - assert Fraction(decoded).limit_denominator() == test_input - - -@pytest.mark.parametrize( - "test_input", [np.random.randint(100, dtype=np.int64) for _ in range(10)] -) -def test_encode_np_int(test_input): - encoded = json.dumps(test_input, cls=JSONEncoder) - decoded = json.loads(encoded) - assert np.int64(decoded) == test_input - - -@pytest.mark.parametrize( - "test_input", [np.float64(np.random.random()) for _ in range(10)] -) -def test_encode_np_float(test_input): - encoded = json.dumps(test_input, cls=JSONEncoder) - decoded = json.loads(encoded) - assert np.float64(decoded) == test_input diff --git a/tests/test_utilities.py b/tests/test_utilities.py deleted file mode 100644 index d2cf4f69..00000000 --- a/tests/test_utilities.py +++ /dev/null @@ -1,31 +0,0 @@ -import numpy as np - -from openflexure_microscope import utilities - - -def test_serialise_array_b64(): - shape_in = (3, 2) - arr_in = np.random.randint(100, size=shape_in, dtype=np.int32) - - b64_string, dtype, shape = utilities.serialise_array_b64(arr_in) - assert b64_string - assert dtype == "int32" - assert shape == shape_in - - arr_out = utilities.deserialise_array_b64(b64_string, dtype, shape) - assert np.array_equal(arr_out, arr_in) - - -def test_ndarray_to_json(): - shape_in = (3, 2) - arr_in = np.random.randint(100, size=shape_in, dtype=np.int32) - - json_out = utilities.ndarray_to_json(arr_in) - - arr_out = utilities.json_to_ndarray(json_out) - assert np.array_equal(arr_out, arr_in) - - -def test_axes_to_array(): - dict_in = {"x": 1, "y": 2, "z": 3} - assert utilities.axes_to_array(dict_in) == [1, 2, 3] diff --git a/tests/w3c_td_schema.json b/tests/w3c_td_schema.json deleted file mode 100644 index e15f1bbf..00000000 --- a/tests/w3c_td_schema.json +++ /dev/null @@ -1,1035 +0,0 @@ -{ - "title": "WoT TD Schema - 16 October 2019", - "description": "JSON Schema for validating TD instances against the TD model. TD instances can be with or without terms that have default values", - "$schema ": "http://json-schema.org/draft-07/schema#", - "definitions": { - "anyUri": { - "type": "string", - "format": "iri-reference" - }, - "description": { - "type": "string" - }, - "descriptions": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "title": { - "type": "string" - }, - "titles": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "security": { - "oneOf": [{ - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "string" - } - ] - }, - "scopes": { - "oneOf": [{ - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "string" - } - ] - }, - "subprotocol": { - "type": "string", - "enum": [ - "longpoll", - "websub", - "sse" - ] - }, - "thing-context-w3c-uri": { - "type": "string", - "enum": [ - "https://www.w3.org/2019/wot/td/v1" - ] - }, - "thing-context": { - "oneOf": [{ - "type": "array", - "prefixItems": [{ - "$ref": "#/definitions/thing-context-w3c-uri" - }], - "items": { - "anyOf": [{ - "$ref": "#/definitions/anyUri" - }, - { - "type": "object" - } - ] - } - }, - { - "$ref": "#/definitions/thing-context-w3c-uri" - } - ] - }, - "type_declaration": { - "oneOf": [{ - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "dataSchema": { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "title": { - "$ref": "#/definitions/title" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "titles": { - "$ref": "#/definitions/titles" - }, - "writeOnly": { - "type": "boolean" - }, - "readOnly": { - "type": "boolean" - }, - "oneOf": { - "type": "array", - "items": { - "$ref": "#/definitions/dataSchema" - } - }, - "unit": { - "type": "string" - }, - "enum": { - "type": "array", - "minItems": 1, - "uniqueItems": true - }, - "format": { - "type": "string" - }, - "const": {}, - "type": { - "type": "string", - "enum": [ - "boolean", - "integer", - "number", - "string", - "object", - "array", - "null" - ] - }, - "items": { - "oneOf": [{ - "$ref": "#/definitions/dataSchema" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/dataSchema" - } - } - ] - }, - "maxItems": { - "type": "integer", - "minimum": 0 - }, - "minItems": { - "type": "integer", - "minimum": 0 - }, - "minimum": { - "type": "number" - }, - "maximum": { - "type": "number" - }, - "properties": { - "additionalProperties": { - "$ref": "#/definitions/dataSchema" - } - }, - "required": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "form_element_property": { - "type": "object", - "properties": { - "op": { - "oneOf": [{ - "type": "string", - "enum": [ - "readproperty", - "writeproperty", - "observeproperty", - "unobserveproperty" - ] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": [ - "readproperty", - "writeproperty", - "observeproperty", - "unobserveproperty" - ] - } - } - ] - }, - "href": { - "$ref": "#/definitions/anyUri" - }, - "contentType": { - "type": "string" - }, - "contentCoding": { - "type": "string" - }, - "subprotocol": { - "$ref": "#/definitions/subprotocol" - }, - "security": { - "$ref": "#/definitions/security" - }, - "scopes": { - "$ref": "#/definitions/scopes" - }, - "response": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - } - } - } - }, - "required": [ - "href" - ], - "additionalProperties": true - }, - "form_element_action": { - "type": "object", - "properties": { - "op": { - "oneOf": [{ - "type": "string", - "enum": [ - "invokeaction" - ] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": [ - "invokeaction" - ] - } - } - ] - }, - "href": { - "$ref": "#/definitions/anyUri" - }, - "contentType": { - "type": "string" - }, - "contentCoding": { - "type": "string" - }, - "subprotocol": { - "$ref": "#/definitions/subprotocol" - }, - "security": { - "$ref": "#/definitions/security" - }, - "scopes": { - "$ref": "#/definitions/scopes" - }, - "response": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - } - } - } - }, - "required": [ - "href" - ], - "additionalProperties": true - }, - "form_element_event": { - "type": "object", - "properties": { - "op": { - "oneOf": [{ - "type": "string", - "enum": [ - "subscribeevent", - "unsubscribeevent" - ] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": [ - "subscribeevent", - "unsubscribeevent" - ] - } - } - ] - }, - "href": { - "$ref": "#/definitions/anyUri" - }, - "contentType": { - "type": "string" - }, - "contentCoding": { - "type": "string" - }, - "subprotocol": { - "$ref": "#/definitions/subprotocol" - }, - "security": { - "$ref": "#/definitions/security" - }, - "scopes": { - "$ref": "#/definitions/scopes" - }, - "response": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - } - } - } - }, - "required": [ - "href" - ], - "additionalProperties": true - }, - "form_element_root": { - "type": "object", - "properties": { - "op": { - "oneOf": [{ - "type": "string", - "enum": [ - "readallproperties", - "writeallproperties", - "readmultipleproperties", - "writemultipleproperties" - ] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": [ - "readallproperties", - "writeallproperties", - "readmultipleproperties", - "writemultipleproperties" - ] - } - } - ] - }, - "href": { - "$ref": "#/definitions/anyUri" - }, - "contentType": { - "type": "string" - }, - "contentCoding": { - "type": "string" - }, - "subprotocol": { - "$ref": "#/definitions/subprotocol" - }, - "security": { - "$ref": "#/definitions/security" - }, - "scopes": { - "$ref": "#/definitions/scopes" - }, - "response": { - "type": "object", - "properties": { - "contentType": { - "type": "string" - } - } - } - }, - "required": [ - "href" - ], - "additionalProperties": true - }, - "property_element": { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "title": { - "$ref": "#/definitions/title" - }, - "titles": { - "$ref": "#/definitions/titles" - }, - "forms": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/form_element_property" - } - }, - "uriVariables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/dataSchema" - } - }, - "observable": { - "type": "boolean" - }, - "writeOnly": { - "type": "boolean" - }, - "readOnly": { - "type": "boolean" - }, - "oneOf": { - "type": "array", - "items": { - "$ref": "#/definitions/dataSchema" - } - }, - "unit": { - "type": "string" - }, - "enum": { - "type": "array", - "minItems": 1, - "uniqueItems": true - }, - "format": { - "type": "string" - }, - "const": {}, - "type": { - "type": "string", - "enum": [ - "boolean", - "integer", - "number", - "string", - "object", - "array", - "null" - ] - }, - "items": { - "oneOf": [{ - "$ref": "#/definitions/dataSchema" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/dataSchema" - } - } - ] - }, - "maxItems": { - "type": "integer", - "minimum": 0 - }, - "minItems": { - "type": "integer", - "minimum": 0 - }, - "minimum": { - "type": "number" - }, - "maximum": { - "type": "number" - }, - "properties": { - "additionalProperties": { - "$ref": "#/definitions/dataSchema" - } - }, - "required": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "forms" - ], - "additionalProperties": true - }, - "action_element": { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "title": { - "$ref": "#/definitions/title" - }, - "titles": { - "$ref": "#/definitions/titles" - }, - "forms": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/form_element_action" - } - }, - "uriVariables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/dataSchema" - } - }, - "input": { - "$ref": "#/definitions/dataSchema" - }, - "output": { - "$ref": "#/definitions/dataSchema" - }, - "safe": { - "type": "boolean" - }, - "idempotent": { - "type": "boolean" - } - }, - "required": [ - "forms" - ], - "additionalProperties": true - }, - "event_element": { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "title": { - "$ref": "#/definitions/title" - }, - "titles": { - "$ref": "#/definitions/titles" - }, - "forms": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/form_element_event" - } - }, - "uriVariables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/dataSchema" - } - }, - "subscription": { - "$ref": "#/definitions/dataSchema" - }, - "data": { - "$ref": "#/definitions/dataSchema" - }, - "cancellation": { - "$ref": "#/definitions/dataSchema" - } - }, - "required": [ - "forms" - ], - "additionalProperties": true - }, - "link_element": { - "type": "object", - "properties": { - "href": { - "$ref": "#/definitions/anyUri" - }, - "type": { - "type": "string" - }, - "rel": { - "type": "string" - }, - "anchor": { - "$ref": "#/definitions/anyUri" - } - }, - "required": [ - "href" - ], - "additionalProperties": true - }, - "securityScheme": { - "oneOf": [{ - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "nosec" - ] - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "basic" - ] - }, - "in": { - "type": "string", - "enum": [ - "header", - "query", - "body", - "cookie" - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "digest" - ] - }, - "qop": { - "type": "string", - "enum": [ - "auth", - "auth-int" - ] - }, - "in": { - "type": "string", - "enum": [ - "header", - "query", - "body", - "cookie" - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "apikey" - ] - }, - "in": { - "type": "string", - "enum": [ - "header", - "query", - "body", - "cookie" - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "bearer" - ] - }, - "authorization": { - "$ref": "#/definitions/anyUri" - }, - "alg": { - "type": "string" - }, - "format": { - "type": "string" - }, - "in": { - "type": "string", - "enum": [ - "header", - "query", - "body", - "cookie" - ] - }, - "name": { - "type": "string" - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "psk" - ] - }, - "identity": { - "type": "string" - } - }, - "required": [ - "scheme" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "proxy": { - "$ref": "#/definitions/anyUri" - }, - "scheme": { - "type": "string", - "enum": [ - "oauth2" - ] - }, - "authorization": { - "$ref": "#/definitions/anyUri" - }, - "token": { - "$ref": "#/definitions/anyUri" - }, - "refresh": { - "$ref": "#/definitions/anyUri" - }, - "scopes": { - "oneOf": [{ - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "string" - } - ] - }, - "flow": { - "type": "string", - "enum": [ - "code" - ] - } - }, - "required": [ - "scheme" - ] - } - ] - } - }, - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uri" - }, - "title": { - "$ref": "#/definitions/title" - }, - "titles": { - "$ref": "#/definitions/titles" - }, - "properties": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/property_element" - } - }, - "actions": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/action_element" - } - }, - "events": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/event_element" - } - }, - "description": { - "$ref": "#/definitions/description" - }, - "descriptions": { - "$ref": "#/definitions/descriptions" - }, - "version": { - "type": "object", - "properties": { - "instance": { - "type": "string" - } - }, - "required": [ - "instance" - ] - }, - "links": { - "type": "array", - "items": { - "$ref": "#/definitions/link_element" - } - }, - "forms": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/form_element_root" - } - }, - "base": { - "$ref": "#/definitions/anyUri" - }, - "securityDefinitions": { - "type": "object", - "minProperties": 1, - "additionalProperties": { - "$ref": "#/definitions/securityScheme" - } - }, - "support": { - "$ref": "#/definitions/anyUri" - }, - "created": { - "type": "string", - "format": "date-time" - }, - "modified": { - "type": "string", - "format": "date-time" - }, - "security": { - "oneOf": [{ - "type": "string" - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - ] - }, - "@type": { - "$ref": "#/definitions/type_declaration" - }, - "@context": { - "$ref": "#/definitions/thing-context" - } - }, - "required": [ - "title", - "security", - "securityDefinitions", - "@context" - ], - "additionalProperties": true -} \ No newline at end of file