diff --git a/src/fastcs_eiger/__main__.py b/src/fastcs_eiger/__main__.py index dada0e8..f691054 100644 --- a/src/fastcs_eiger/__main__.py +++ b/src/fastcs_eiger/__main__.py @@ -11,6 +11,7 @@ from fastcs_eiger import __version__ from fastcs_eiger.controllers.eiger_controller import EigerController from fastcs_eiger.controllers.odin.eiger_odin_controller import EigerOdinController +from fastcs_eiger.eiger_parameter import EigerAPIVersion __all__ = ["main"] @@ -47,6 +48,7 @@ def ioc( pv_prefix: str = typer.Argument(), ip: str = typer.Option("127.0.0.1", help="IP address of Eiger detector"), port: int = typer.Option(8081, help="Port of Eiger HTTP server"), + api_version: EigerAPIVersion = typer.Option("1.8.0", help="Version of Eiger API"), # noqa: B008 odin_ip: str | None = typer.Option(None, help="IP address of odin control server"), odin_port: int = typer.Option(8888, help="Port of odin control server"), log_level: LogLevel = LogLevel.TRACE, @@ -58,11 +60,13 @@ def ioc( if odin_ip is None: controller = EigerController( connection_settings=IPConnectionSettings(ip=ip, port=port), + api_version=api_version, ) else: controller = EigerOdinController( detector_connection_settings=IPConnectionSettings(ip=ip, port=port), odin_connection_settings=IPConnectionSettings(ip=odin_ip, port=odin_port), + api_version=api_version, ) transports = [ diff --git a/src/fastcs_eiger/controllers/eiger_controller.py b/src/fastcs_eiger/controllers/eiger_controller.py index 72c6315..ca22d9b 100644 --- a/src/fastcs_eiger/controllers/eiger_controller.py +++ b/src/fastcs_eiger/controllers/eiger_controller.py @@ -12,7 +12,7 @@ from fastcs_eiger.controllers.eiger_monitor_controller import EigerMonitorController from fastcs_eiger.controllers.eiger_stream_controller import EigerStreamController from fastcs_eiger.controllers.eiger_subsystem_controller import EigerSubsystemController -from fastcs_eiger.eiger_parameter import EIGER_PARAMETER_SUBSYSTEMS +from fastcs_eiger.eiger_parameter import EIGER_PARAMETER_SUBSYSTEMS, EigerAPIVersion from fastcs_eiger.http_connection import HTTPConnection, HTTPRequestError @@ -27,15 +27,17 @@ class EigerController(Controller): # Internal Attribute stale_parameters = AttrR(Bool()) - def __init__(self, connection_settings: IPConnectionSettings) -> None: + def __init__( + self, connection_settings: IPConnectionSettings, api_version: EigerAPIVersion + ) -> None: super().__init__() self.connection_settings = connection_settings - self.logger = bind_logger(__class__.__name__) self.connection = HTTPConnection(connection_settings) self._parameter_update_lock = asyncio.Lock() self.queue = asyncio.Queue() + self._api_version: EigerAPIVersion = api_version async def initialise(self) -> None: """Create attributes by introspecting detector. @@ -52,12 +54,13 @@ async def initialise(self) -> None: controller = EigerDetectorController( self.connection, self.queue_subsystem_update, + self._api_version, ) # detector subsystem initialises first # Check current state of detector_state to see # if initializing is required. state_val = await self.connection.get( - "detector/api/1.8.0/status/state" + f"detector/api/{self._api_version}/status/state" ) if state_val["value"] == "na": print("Initializing Detector") @@ -67,16 +70,19 @@ async def initialise(self) -> None: controller = EigerMonitorController( self.connection, self.queue_subsystem_update, + self._api_version, ) case "stream": controller = EigerStreamController( self.connection, self.queue_subsystem_update, + self._api_version, ) case _: raise NotImplementedError( f"No subcontroller implemented for subsystem {subsystem}" ) + self.add_sub_controller(subsystem.capitalize(), controller) await controller.initialise() diff --git a/src/fastcs_eiger/controllers/eiger_detector_controller.py b/src/fastcs_eiger/controllers/eiger_detector_controller.py index 71377a1..f7b6a43 100644 --- a/src/fastcs_eiger/controllers/eiger_detector_controller.py +++ b/src/fastcs_eiger/controllers/eiger_detector_controller.py @@ -5,10 +5,11 @@ from fastcs.methods import command from fastcs_eiger.controllers.eiger_subsystem_controller import EigerSubsystemController +from fastcs_eiger.eiger_parameter import EigerAPIVersion -def command_uri(key: str) -> str: - return f"detector/api/1.8.0/command/{key}" +def command_uri(api_version: EigerAPIVersion, key: str) -> str: + return f"detector/api/{api_version}/command/{key}" def detector_command(fn) -> Any: @@ -25,30 +26,32 @@ class EigerDetectorController(EigerSubsystemController): @detector_command async def initialize(self): - await self.connection.put(command_uri("initialize")) + await self.connection.put(command_uri(self._api_version, key="initialize")) @detector_command async def arm(self): - await self.connection.put(command_uri("arm")) + await self.connection.put(command_uri(self._api_version, key="arm")) @detector_command async def trigger(self): match self.trigger_mode.get(), self.trigger_exposure.get(): case ("inte", exposure) if exposure > 0.0: - await self.connection.put(command_uri("trigger"), exposure) + await self.connection.put( + command_uri(self._api_version, key="trigger"), exposure + ) case ("ints" | "inte", _): - await self.connection.put(command_uri("trigger")) + await self.connection.put(command_uri(self._api_version, key="trigger")) case _: raise RuntimeError("Can only do soft trigger in 'ints' or 'inte' mode") @detector_command async def disarm(self): - await self.connection.put(command_uri("disarm")) + await self.connection.put(command_uri(self._api_version, key="disarm")) @detector_command async def abort(self): - await self.connection.put(command_uri("abort")) + await self.connection.put(command_uri(self._api_version, key="abort")) @detector_command async def cancel(self): - await self.connection.put(command_uri("cancel")) + await self.connection.put(command_uri(self._api_version, key="cancel")) diff --git a/src/fastcs_eiger/controllers/eiger_monitor_controller.py b/src/fastcs_eiger/controllers/eiger_monitor_controller.py index 3479620..2c5b671 100644 --- a/src/fastcs_eiger/controllers/eiger_monitor_controller.py +++ b/src/fastcs_eiger/controllers/eiger_monitor_controller.py @@ -14,7 +14,7 @@ class EigerMonitorController(EigerSubsystemController): async def handle_monitor(self): """Poll monitor images to display.""" response, image_bytes = await self.connection.get_bytes( - "monitor/api/1.8.0/images/next" + f"monitor/api/{self._api_version}/images/next" ) if response.status != 200: return diff --git a/src/fastcs_eiger/controllers/eiger_subsystem_controller.py b/src/fastcs_eiger/controllers/eiger_subsystem_controller.py index d6f5942..ee16314 100644 --- a/src/fastcs_eiger/controllers/eiger_subsystem_controller.py +++ b/src/fastcs_eiger/controllers/eiger_subsystem_controller.py @@ -9,6 +9,7 @@ from fastcs_eiger.eiger_parameter import ( EIGER_PARAMETER_MODES, + EigerAPIVersion, EigerParameterRef, EigerParameterResponse, key_to_attribute_name, @@ -54,6 +55,7 @@ def __init__( self, connection: HTTPConnection, queue_subsystem_update: Callable[[list[Coroutine]], Coroutine], + api_version: EigerAPIVersion, ): self.logger = bind_logger(__class__.__name__) @@ -61,6 +63,7 @@ def __init__( self._queue_subsystem_update = queue_subsystem_update self._io = EigerAttributeIO(connection, self.update_now, self.queue_update) super().__init__(ios=[self._io]) + self._api_version: EigerAPIVersion = api_version async def _introspect_detector_subsystem(self) -> list[EigerParameterRef]: parameters = [] @@ -68,12 +71,14 @@ async def _introspect_detector_subsystem(self) -> list[EigerParameterRef]: subsystem_keys = [ parameter for parameter in await self.connection.get( - f"{self._subsystem}/api/1.8.0/{mode}/keys" + f"{self._subsystem}/api/{self._api_version}/{mode}/keys" ) if parameter not in IGNORED_KEYS ] + MISSING_KEYS[self._subsystem][mode] requests = [ - self.connection.get(f"{self._subsystem}/api/1.8.0/{mode}/{key}") + self.connection.get( + f"{self._subsystem}/api/{self._api_version}/{mode}/{key}" + ) for key in subsystem_keys ] responses = await asyncio.gather(*requests) @@ -83,6 +88,7 @@ async def _introspect_detector_subsystem(self) -> list[EigerParameterRef]: EigerParameterRef( key=key, subsystem=self._subsystem, + api_version=self._api_version, mode=mode, response=EigerParameterResponse.model_validate(response), update_period=ONCE if mode == "config" else 0.2, @@ -119,7 +125,7 @@ def _create_attributes(cls, parameters: list[EigerParameterRef]): attributes: dict[str, Attribute] = {} for parameter in parameters: group = cls._group(parameter) - match parameter.response.access_mode: + match parameter.access_mode: case "r": attributes[parameter.attribute_name] = AttrR( parameter.fastcs_datatype, diff --git a/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py b/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py index 7b4bca8..a24e95c 100644 --- a/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py +++ b/src/fastcs_eiger/controllers/odin/eiger_odin_controller.py @@ -4,6 +4,7 @@ from fastcs_eiger.controllers.eiger_controller import EigerController from fastcs_eiger.controllers.odin.odin_controller import OdinController +from fastcs_eiger.eiger_parameter import EigerAPIVersion class EigerOdinController(EigerController): @@ -13,8 +14,9 @@ def __init__( self, detector_connection_settings: IPConnectionSettings, odin_connection_settings: IPConnectionSettings, + api_version: EigerAPIVersion, ) -> None: - super().__init__(detector_connection_settings) + super().__init__(detector_connection_settings, api_version) self.OD = OdinController(odin_connection_settings) diff --git a/src/fastcs_eiger/eiger_parameter.py b/src/fastcs_eiger/eiger_parameter.py index 17f384b..cc7d0e2 100644 --- a/src/fastcs_eiger/eiger_parameter.py +++ b/src/fastcs_eiger/eiger_parameter.py @@ -5,9 +5,11 @@ from fastcs.datatypes import Bool, DataType, Float, Int, String from pydantic import BaseModel +EigerAPIVersion = Literal["1.6.0", "1.8.0"] + class EigerParameterResponse(BaseModel): - access_mode: Literal["r", "w", "rw"] + access_mode: Literal["r", "w", "rw"] | None = None allowed_values: Any | None = None min: float | int | None = None value: Any @@ -26,6 +28,8 @@ class EigerParameterRef(AttributeIORef): """Last section of URI within a subsystem/mode.""" subsystem: Literal["detector", "stream", "monitor"] """Subsystem within detector API.""" + api_version: EigerAPIVersion = "1.8.0" + """Version of API to use.""" mode: Literal["status", "config"] """Mode of parameter within subsystem.""" response: EigerParameterResponse @@ -38,7 +42,7 @@ def attribute_name(self): @property def uri(self) -> str: """Full URI for HTTP requests.""" - return f"{self.subsystem}/api/1.8.0/{self.mode}/{self.key}" + return f"{self.subsystem}/api/{self.api_version}/{self.mode}/{self.key}" @property def fastcs_datatype(self) -> DataType: @@ -52,6 +56,16 @@ def fastcs_datatype(self) -> DataType: case "string" | "datetime" | "State" | "string[]": return String() + @property + def access_mode(self) -> Literal["r", "w", "rw"] | None: + if self.response.access_mode is None: + if self.mode == "status": + return "r" + elif self.mode == "config": + return "rw" + else: + return self.response.access_mode + def __repr__(self): name = self.__class__.__name__ return f"{name}(subsystem={self.subsystem}, mode={self.mode}, key={self.key})" diff --git a/tests/conftest.py b/tests/conftest.py index 7ba84fc..2effabf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,7 +58,9 @@ def sim_eiger(request): @pytest.fixture def mock_connection(mocker: MockerFixture): - eiger_controller = EigerController(IPConnectionSettings("127.0.0.1", 80)) + eiger_controller = EigerController( + IPConnectionSettings("127.0.0.1", 80), api_version="1.8.0" + ) connection = mocker.patch.object(eiger_controller, "connection") connection.get = mock.AsyncMock() connection.put = mock.AsyncMock() diff --git a/tests/system/test_eiger_introspection.py b/tests/system/test_eiger_introspection.py index 2177131..321883d 100644 --- a/tests/system/test_eiger_introspection.py +++ b/tests/system/test_eiger_introspection.py @@ -45,7 +45,9 @@ def _serialise_parameter(parameter: EigerParameterRef) -> dict: @pytest.mark.asyncio @pytest.mark.parametrize("sim_eiger", [str(HERE / "eiger.yaml")], indirect=True) async def test_attribute_creation(sim_eiger): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() serialised_parameters: dict[str, dict[str, Any]] = {} subsystem_parameters = {} @@ -95,7 +97,9 @@ async def test_attribute_creation(sim_eiger): @pytest.mark.asyncio @pytest.mark.parametrize("sim_eiger", [str(HERE / "eiger.yaml")], indirect=True) async def test_controller_groups_and_parameters(sim_eiger): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() for subsystem in MISSING_KEYS: @@ -126,7 +130,9 @@ async def test_controller_groups_and_parameters(sim_eiger): async def test_threshold_mode_api_inconsistency_handled( sim_eiger, mocker: MockerFixture ): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() detector_controller = controller.sub_controllers["Detector"] @@ -157,7 +163,9 @@ async def test_threshold_mode_api_inconsistency_handled( async def test_fetch_before_returning_parameters(sim_eiger, mocker: MockerFixture): # Need to mock @scan to spy controller.update() with patch("fastcs_eiger.controllers.eiger_controller.scan"): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() detector_controller = controller.sub_controllers["Detector"] @@ -209,7 +217,9 @@ async def test_fetch_before_returning_parameters(sim_eiger, mocker: MockerFixtur async def test_stale_propagates_to_top_controller( sim_eiger, ): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() detector_controller = controller.sub_controllers["Detector"] @@ -268,7 +278,9 @@ async def test_attribute_validation_accepts_valid_types(mock_connection, valid_t async def test_eiger_controller_trigger_correctly_introspected( mocker: MockerFixture, sim_eiger ): - controller = EigerController(IPConnectionSettings("127.0.0.1", 8081)) + controller = EigerController( + IPConnectionSettings("127.0.0.1", 8081), api_version="1.8.0" + ) await controller.initialise() detector_controller = controller.sub_controllers["Detector"] diff --git a/tests/test_eiger_controller.py b/tests/test_eiger_controller.py index 20a3e02..159be39 100644 --- a/tests/test_eiger_controller.py +++ b/tests/test_eiger_controller.py @@ -37,11 +37,14 @@ async def test_eiger_controller_creates_subcontrollers(mock_connection): def subsystem_controller_and_connection(mock_connection): controller, connection = mock_connection subsystem_controller = EigerDetectorController( - connection, controller.queue_subsystem_update + connection, + controller.queue_subsystem_update, + api_version="1.8.0", ) ref = EigerParameterRef( key="dummy_uri", subsystem="detector", + api_version="1.8.0", mode="config", response=EigerParameterResponse( access_mode="rw", value=0.0, value_type="float" @@ -83,6 +86,7 @@ async def test_eiger_io_send( ref = EigerParameterRef( key="no_updated_params", subsystem="detector", + api_version="1.8.0", mode="config", response=EigerParameterResponse( access_mode="rw", value=0.0, value_type="float" @@ -95,3 +99,31 @@ async def test_eiger_io_send( await io.send(subsystem_controller.no_updated_params, 0.1) connection.put.assert_awaited_with(no_updated_params_uri, 0.1) io.queue_update.assert_awaited_with(["no_updated_params"]) + + +@pytest.mark.asyncio +async def test_eiger_accepts_different_api_versions(): + + ref = EigerParameterRef( + key="dummy_uri", + subsystem="detector", + api_version="1.6.0", + mode="config", + response=EigerParameterResponse( + access_mode="rw", value=0.0, value_type="float" + ), + ) + + assert ref.uri == "detector/api/1.6.0/config/dummy_uri" + + ref = EigerParameterRef( + key="dummy_uri", + subsystem="detector", + api_version="1.8.0", + mode="config", + response=EigerParameterResponse( + access_mode="rw", value=0.0, value_type="float" + ), + ) + + assert ref.uri == "detector/api/1.8.0/config/dummy_uri" diff --git a/tests/test_eiger_odin_controller.py b/tests/test_eiger_odin_controller.py index 030777e..d18c581 100644 --- a/tests/test_eiger_odin_controller.py +++ b/tests/test_eiger_odin_controller.py @@ -12,7 +12,7 @@ async def test_eiger_odin_controller(mocker: MockerFixture): odin_connection_settings = IPConnectionSettings("127.0.0.1", 8001) controller = EigerOdinController( - detector_connection_settings, odin_connection_settings + detector_connection_settings, odin_connection_settings, api_version="1.8.0" ) assert isinstance(controller.OD, OdinController) diff --git a/tests/test_eiger_parameter.py b/tests/test_eiger_parameter.py new file mode 100644 index 0000000..b323432 --- /dev/null +++ b/tests/test_eiger_parameter.py @@ -0,0 +1,27 @@ +import pytest + +from fastcs_eiger.eiger_parameter import EigerParameterRef, EigerParameterResponse + + +@pytest.mark.parametrize( + "api_version, mode, response_access_mode, expected_access_mode", + [ + ("1.8.0", "config", "r", "r"), # If access_mode exists, mode ignored + ("1.8.0", "status", "rw", "rw"), + ("1.6.0", "status", None, "r"), # If no access_mode, infer from mode + ("1.6.0", "config", None, "rw"), + ], +) +def test_eiger_access_mode( + api_version, mode, response_access_mode, expected_access_mode +): + ref = EigerParameterRef( + key="dummy_uri", + subsystem="detector", + api_version=api_version, + mode=mode, + response=EigerParameterResponse( + access_mode=response_access_mode, value=0.0, value_type="float" + ), + ) + assert ref.access_mode == expected_access_mode