diff --git a/tests/acceptance/test_nidigital_measurement.py b/tests/acceptance/test_nidigital_measurement.py
new file mode 100644
index 000000000..6af946418
--- /dev/null
+++ b/tests/acceptance/test_nidigital_measurement.py
@@ -0,0 +1,121 @@
+import pathlib
+from typing import Generator, Iterable, NamedTuple
+
+import pytest
+
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.measurement.v2.measurement_service_pb2 import (
+ MeasureRequest,
+)
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.measurement.v2.measurement_service_pb2_grpc import (
+ MeasurementServiceStub,
+)
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.pin_map_context_pb2 import (
+ PinMapContext,
+)
+from ni_measurementlink_service.measurement.service import MeasurementService
+from tests.assets.stubs.nidigital_measurement.types_pb2 import Configurations, Outputs
+from tests.utilities import nidigital_measurement
+from tests.utilities.pin_map_client import PinMapClient
+
+
+def test___single_session___measure___returns_measured_values(
+ pin_map_id: str,
+ stub_v2: MeasurementServiceStub,
+) -> None:
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0])
+ configurations = Configurations(pin_names=["CS", "SCLK", "MOSI", "MISO"], multi_session=False)
+
+ outputs = _measure(stub_v2, pin_map_context, configurations)
+
+ assert outputs.passing_sites == [0]
+ assert outputs.failing_sites == []
+
+
+def test___single_session___measure___creates_single_session(
+ pin_map_id: str,
+ stub_v2: MeasurementServiceStub,
+) -> None:
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0])
+ configurations = Configurations(pin_names=["CS", "SCLK", "MOSI", "MISO"], multi_session=False)
+
+ outputs = _measure(stub_v2, pin_map_context, configurations)
+
+ assert _get_output(outputs) == [
+ _MeasurementOutput(
+ "DigitalPattern1",
+ "DigitalPattern1",
+ "site0/CS, site0/SCLK, site0/MOSI, site0/MISO",
+ "site0/CS",
+ )
+ ]
+
+
+def test___multiple_sessions___measure___creates_multiple_sessions(
+ pin_map_id: str,
+ stub_v2: MeasurementServiceStub,
+) -> None:
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0, 1])
+ configurations = Configurations(pin_names=["CS", "SCLK", "MOSI", "MISO"], multi_session=True)
+
+ outputs = _measure(stub_v2, pin_map_context, configurations)
+
+ assert _get_output(outputs) == [
+ _MeasurementOutput(
+ "DigitalPattern1",
+ "DigitalPattern1",
+ "site0/CS, site0/SCLK, site0/MOSI, site0/MISO",
+ "site0/CS, site0/SCLK, site0/MOSI, site0/MISO",
+ ),
+ _MeasurementOutput(
+ "DigitalPattern2",
+ "DigitalPattern2",
+ "site1/CS, site1/SCLK, site1/MOSI, site1/MISO",
+ "site1/CS, site1/SCLK, site1/MOSI, site1/MISO",
+ ),
+ ]
+
+
+def _measure(
+ stub_v2: MeasurementServiceStub,
+ pin_map_context: PinMapContext,
+ configurations: Configurations,
+) -> Outputs:
+ request = MeasureRequest(pin_map_context=pin_map_context)
+ request.configuration_parameters.Pack(configurations)
+ response_iterator = stub_v2.Measure(request)
+ responses = list(response_iterator)
+ assert len(responses) == 1
+ outputs = Outputs.FromString(responses[0].outputs.value)
+ return outputs
+
+
+@pytest.fixture(scope="module")
+def measurement_service() -> Generator[MeasurementService, None, None]:
+ """Test fixture that creates and hosts a measurement service."""
+ with nidigital_measurement.measurement_service.host_service() as service:
+ yield service
+
+
+@pytest.fixture
+def pin_map_id(pin_map_client: PinMapClient, pin_map_directory: pathlib.Path) -> str:
+ pin_map_name = "2Digital2Group4Pin1Site.pinmap"
+ return pin_map_client.update_pin_map(pin_map_directory / pin_map_name)
+
+
+class _MeasurementOutput(NamedTuple):
+ session_name: str
+ resource_name: str
+ channel_list: str
+ connected_channels: str
+
+
+def _get_output(outputs: Outputs) -> Iterable[_MeasurementOutput]:
+ return [
+ _MeasurementOutput(session_name, resource_name, channel_list, connected_channels)
+ for session_name, resource_name, channel_list, connected_channels in zip(
+ outputs.session_names,
+ outputs.resource_names,
+ outputs.channel_lists,
+ outputs.connected_channels,
+ )
+ ]
diff --git a/tests/acceptance/test_niswitch_measurement.py b/tests/acceptance/test_niswitch_measurement.py
new file mode 100644
index 000000000..2b156ddcf
--- /dev/null
+++ b/tests/acceptance/test_niswitch_measurement.py
@@ -0,0 +1,96 @@
+import pathlib
+from typing import Generator, Iterable, NamedTuple
+
+import pytest
+
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.measurement.v2.measurement_service_pb2 import (
+ MeasureRequest,
+)
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.measurement.v2.measurement_service_pb2_grpc import (
+ MeasurementServiceStub,
+)
+from ni_measurementlink_service._internal.stubs.ni.measurementlink.pin_map_context_pb2 import (
+ PinMapContext,
+)
+from ni_measurementlink_service.measurement.service import MeasurementService
+from tests.assets.stubs.niswitch_measurement.types_pb2 import (
+ Configurations,
+ Outputs,
+)
+from tests.utilities import niswitch_measurement
+from tests.utilities.pin_map_client import PinMapClient
+
+_SITE = 0
+
+
+def test___single_session___measure___creates_single_session(
+ pin_map_context: PinMapContext,
+ stub_v2: MeasurementServiceStub,
+) -> None:
+ configurations = Configurations(relay_names=["SiteRelay1"], multi_session=False)
+
+ outputs = _measure(stub_v2, pin_map_context, configurations)
+
+ assert _get_output(outputs) == [_MeasurementOutput("RelayDriver1", "RelayDriver1", "K0", "K0")]
+
+
+def test___multiple_sessions___measure___creates_multiple_sessions(
+ pin_map_context: PinMapContext,
+ stub_v2: MeasurementServiceStub,
+) -> None:
+ configurations = Configurations(relay_names=["SiteRelay1", "SiteRelay2"], multi_session=True)
+
+ outputs = _measure(stub_v2, pin_map_context, configurations)
+
+ assert _get_output(outputs) == [
+ _MeasurementOutput("RelayDriver1", "RelayDriver1", "K0", "K0"),
+ _MeasurementOutput("RelayDriver2", "RelayDriver2", "K1", "K1"),
+ ]
+
+
+def _measure(
+ stub_v2: MeasurementServiceStub,
+ pin_map_context: PinMapContext,
+ configurations: Configurations,
+) -> Outputs:
+ request = MeasureRequest(pin_map_context=pin_map_context)
+ request.configuration_parameters.Pack(configurations)
+ response_iterator = stub_v2.Measure(request)
+ responses = list(response_iterator)
+ assert len(responses) == 1
+ outputs = Outputs.FromString(responses[0].outputs.value)
+ return outputs
+
+
+@pytest.fixture(scope="module")
+def measurement_service() -> Generator[MeasurementService, None, None]:
+ """Test fixture that creates and hosts a measurement service."""
+ with niswitch_measurement.measurement_service.host_service() as service:
+ yield service
+
+
+@pytest.fixture
+def pin_map_context(pin_map_client: PinMapClient, pin_map_directory: pathlib.Path) -> PinMapContext:
+ pin_map_name = "2Switch2Relay1Site.pinmap"
+ pin_map_id = pin_map_client.update_pin_map(pin_map_directory / pin_map_name)
+
+ return PinMapContext(pin_map_id=pin_map_id, sites=[_SITE])
+
+
+class _MeasurementOutput(NamedTuple):
+ session_name: str
+ resource_name: str
+ channel_list: str
+ connected_channels: str
+
+
+def _get_output(outputs: Outputs) -> Iterable[_MeasurementOutput]:
+ return [
+ _MeasurementOutput(session_name, resource_name, channel_list, connected_channels)
+ for session_name, resource_name, channel_list, connected_channels in zip(
+ outputs.session_names,
+ outputs.resource_names,
+ outputs.channel_lists,
+ outputs.connected_channels,
+ )
+ ]
diff --git a/tests/assets/acceptance/session_management/2Digital2Group4Pin1Site.pinmap b/tests/assets/acceptance/session_management/2Digital2Group4Pin1Site.pinmap
new file mode 100644
index 000000000..1054c1965
--- /dev/null
+++ b/tests/assets/acceptance/session_management/2Digital2Group4Pin1Site.pinmap
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/assets/acceptance/session_management/2Switch2Relay1Site.pinmap b/tests/assets/acceptance/session_management/2Switch2Relay1Site.pinmap
new file mode 100644
index 000000000..cf9e89756
--- /dev/null
+++ b/tests/assets/acceptance/session_management/2Switch2Relay1Site.pinmap
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/assets/integration/session_management/2Digital2Group4Pin1Site.pinmap b/tests/assets/integration/session_management/2Digital2Group4Pin1Site.pinmap
new file mode 100644
index 000000000..1054c1965
--- /dev/null
+++ b/tests/assets/integration/session_management/2Digital2Group4Pin1Site.pinmap
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/assets/integration/session_management/2Switch2Relay1Site.pinmap b/tests/assets/integration/session_management/2Switch2Relay1Site.pinmap
new file mode 100644
index 000000000..cf9e89756
--- /dev/null
+++ b/tests/assets/integration/session_management/2Switch2Relay1Site.pinmap
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/assets/stubs/nidigital_measurement/__init__.py b/tests/assets/stubs/nidigital_measurement/__init__.py
new file mode 100644
index 000000000..10eddb732
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/__init__.py
@@ -0,0 +1 @@
+"""Auto generated gRPC files for nidigital test measurement."""
diff --git a/tests/assets/stubs/nidigital_measurement/types.proto b/tests/assets/stubs/nidigital_measurement/types.proto
new file mode 100644
index 000000000..1ce30e29a
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/types.proto
@@ -0,0 +1,16 @@
+syntax = "proto3";
+package ni.measurementlink.measurement.tests.nidigital_measurement;
+
+message Configurations {
+ repeated string pin_names = 1;
+ bool multi_session = 2;
+}
+
+message Outputs {
+ repeated string session_names = 1;
+ repeated string resource_names = 2;
+ repeated string channel_lists = 3;
+ repeated string connected_channels = 4;
+ repeated int32 passing_sites = 5;
+ repeated int32 failing_sites = 6;
+}
\ No newline at end of file
diff --git a/tests/assets/stubs/nidigital_measurement/types_pb2.py b/tests/assets/stubs/nidigital_measurement/types_pb2.py
new file mode 100644
index 000000000..177fcdc73
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/types_pb2.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: stubs/nidigital_measurement/types.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\'stubs/nidigital_measurement/types.proto\x12:ni.measurementlink.measurement.tests.nidigital_measurement\":\n\x0e\x43onfigurations\x12\x11\n\tpin_names\x18\x01 \x03(\t\x12\x15\n\rmulti_session\x18\x02 \x01(\x08\"\x99\x01\n\x07Outputs\x12\x15\n\rsession_names\x18\x01 \x03(\t\x12\x16\n\x0eresource_names\x18\x02 \x03(\t\x12\x15\n\rchannel_lists\x18\x03 \x03(\t\x12\x1a\n\x12\x63onnected_channels\x18\x04 \x03(\t\x12\x15\n\rpassing_sites\x18\x05 \x03(\x05\x12\x15\n\rfailing_sites\x18\x06 \x03(\x05\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stubs.nidigital_measurement.types_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _CONFIGURATIONS._serialized_start=103
+ _CONFIGURATIONS._serialized_end=161
+ _OUTPUTS._serialized_start=164
+ _OUTPUTS._serialized_end=317
+# @@protoc_insertion_point(module_scope)
diff --git a/tests/assets/stubs/nidigital_measurement/types_pb2.pyi b/tests/assets/stubs/nidigital_measurement/types_pb2.pyi
new file mode 100644
index 000000000..c448f2b36
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/types_pb2.pyi
@@ -0,0 +1,72 @@
+"""
+@generated by mypy-protobuf. Do not edit manually!
+isort:skip_file
+"""
+import builtins
+import collections.abc
+import google.protobuf.descriptor
+import google.protobuf.internal.containers
+import google.protobuf.message
+import sys
+
+if sys.version_info >= (3, 8):
+ import typing as typing_extensions
+else:
+ import typing_extensions
+
+DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
+
+@typing_extensions.final
+class Configurations(google.protobuf.message.Message):
+ DESCRIPTOR: google.protobuf.descriptor.Descriptor
+
+ PIN_NAMES_FIELD_NUMBER: builtins.int
+ MULTI_SESSION_FIELD_NUMBER: builtins.int
+ @property
+ def pin_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ multi_session: builtins.bool
+ def __init__(
+ self,
+ *,
+ pin_names: collections.abc.Iterable[builtins.str] | None = ...,
+ multi_session: builtins.bool = ...,
+ ) -> None: ...
+ def ClearField(self, field_name: typing_extensions.Literal["multi_session", b"multi_session", "pin_names", b"pin_names"]) -> None: ...
+
+global___Configurations = Configurations
+
+@typing_extensions.final
+class Outputs(google.protobuf.message.Message):
+ DESCRIPTOR: google.protobuf.descriptor.Descriptor
+
+ SESSION_NAMES_FIELD_NUMBER: builtins.int
+ RESOURCE_NAMES_FIELD_NUMBER: builtins.int
+ CHANNEL_LISTS_FIELD_NUMBER: builtins.int
+ CONNECTED_CHANNELS_FIELD_NUMBER: builtins.int
+ PASSING_SITES_FIELD_NUMBER: builtins.int
+ FAILING_SITES_FIELD_NUMBER: builtins.int
+ @property
+ def session_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def resource_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def channel_lists(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def connected_channels(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def passing_sites(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.int]: ...
+ @property
+ def failing_sites(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.int]: ...
+ def __init__(
+ self,
+ *,
+ session_names: collections.abc.Iterable[builtins.str] | None = ...,
+ resource_names: collections.abc.Iterable[builtins.str] | None = ...,
+ channel_lists: collections.abc.Iterable[builtins.str] | None = ...,
+ connected_channels: collections.abc.Iterable[builtins.str] | None = ...,
+ passing_sites: collections.abc.Iterable[builtins.int] | None = ...,
+ failing_sites: collections.abc.Iterable[builtins.int] | None = ...,
+ ) -> None: ...
+ def ClearField(self, field_name: typing_extensions.Literal["channel_lists", b"channel_lists", "connected_channels", b"connected_channels", "failing_sites", b"failing_sites", "passing_sites", b"passing_sites", "resource_names", b"resource_names", "session_names", b"session_names"]) -> None: ...
+
+global___Outputs = Outputs
diff --git a/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.py b/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.py
new file mode 100644
index 000000000..2daafffeb
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.pyi b/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.pyi
new file mode 100644
index 000000000..b13382f6a
--- /dev/null
+++ b/tests/assets/stubs/nidigital_measurement/types_pb2_grpc.pyi
@@ -0,0 +1,17 @@
+"""
+@generated by mypy-protobuf. Do not edit manually!
+isort:skip_file
+"""
+import abc
+import collections.abc
+import grpc
+import grpc.aio
+import typing
+
+_T = typing.TypeVar('_T')
+
+class _MaybeAsyncIterator(collections.abc.AsyncIterator[_T], collections.abc.Iterator[_T], metaclass=abc.ABCMeta):
+ ...
+
+class _ServicerContext(grpc.ServicerContext, grpc.aio.ServicerContext): # type: ignore
+ ...
diff --git a/tests/assets/stubs/niswitch_measurement/__init__.py b/tests/assets/stubs/niswitch_measurement/__init__.py
new file mode 100644
index 000000000..aa66b3a5d
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/__init__.py
@@ -0,0 +1 @@
+"""Auto generated gRPC files for niswitch test measurement."""
diff --git a/tests/assets/stubs/niswitch_measurement/types.proto b/tests/assets/stubs/niswitch_measurement/types.proto
new file mode 100644
index 000000000..7ff340cb0
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/types.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+package ni.measurementlink.measurement.tests.niswitch_measurement;
+
+message Configurations {
+ repeated string relay_names = 1;
+ bool multi_session = 2;
+}
+
+message Outputs {
+ repeated string session_names = 1;
+ repeated string resource_names = 2;
+ repeated string channel_lists = 3;
+ repeated string connected_channels = 4;
+}
\ No newline at end of file
diff --git a/tests/assets/stubs/niswitch_measurement/types_pb2.py b/tests/assets/stubs/niswitch_measurement/types_pb2.py
new file mode 100644
index 000000000..dbe3635b2
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/types_pb2.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: stubs/niswitch_measurement/types.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&stubs/niswitch_measurement/types.proto\x12\x39ni.measurementlink.measurement.tests.niswitch_measurement\"<\n\x0e\x43onfigurations\x12\x13\n\x0brelay_names\x18\x01 \x03(\t\x12\x15\n\rmulti_session\x18\x02 \x01(\x08\"k\n\x07Outputs\x12\x15\n\rsession_names\x18\x01 \x03(\t\x12\x16\n\x0eresource_names\x18\x02 \x03(\t\x12\x15\n\rchannel_lists\x18\x03 \x03(\t\x12\x1a\n\x12\x63onnected_channels\x18\x04 \x03(\tb\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stubs.niswitch_measurement.types_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _CONFIGURATIONS._serialized_start=101
+ _CONFIGURATIONS._serialized_end=161
+ _OUTPUTS._serialized_start=163
+ _OUTPUTS._serialized_end=270
+# @@protoc_insertion_point(module_scope)
diff --git a/tests/assets/stubs/niswitch_measurement/types_pb2.pyi b/tests/assets/stubs/niswitch_measurement/types_pb2.pyi
new file mode 100644
index 000000000..6bdcb40cc
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/types_pb2.pyi
@@ -0,0 +1,64 @@
+"""
+@generated by mypy-protobuf. Do not edit manually!
+isort:skip_file
+"""
+import builtins
+import collections.abc
+import google.protobuf.descriptor
+import google.protobuf.internal.containers
+import google.protobuf.message
+import sys
+
+if sys.version_info >= (3, 8):
+ import typing as typing_extensions
+else:
+ import typing_extensions
+
+DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
+
+@typing_extensions.final
+class Configurations(google.protobuf.message.Message):
+ DESCRIPTOR: google.protobuf.descriptor.Descriptor
+
+ RELAY_NAMES_FIELD_NUMBER: builtins.int
+ MULTI_SESSION_FIELD_NUMBER: builtins.int
+ @property
+ def relay_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ multi_session: builtins.bool
+ def __init__(
+ self,
+ *,
+ relay_names: collections.abc.Iterable[builtins.str] | None = ...,
+ multi_session: builtins.bool = ...,
+ ) -> None: ...
+ def ClearField(self, field_name: typing_extensions.Literal["multi_session", b"multi_session", "relay_names", b"relay_names"]) -> None: ...
+
+global___Configurations = Configurations
+
+@typing_extensions.final
+class Outputs(google.protobuf.message.Message):
+ DESCRIPTOR: google.protobuf.descriptor.Descriptor
+
+ SESSION_NAMES_FIELD_NUMBER: builtins.int
+ RESOURCE_NAMES_FIELD_NUMBER: builtins.int
+ CHANNEL_LISTS_FIELD_NUMBER: builtins.int
+ CONNECTED_CHANNELS_FIELD_NUMBER: builtins.int
+ @property
+ def session_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def resource_names(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def channel_lists(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ @property
+ def connected_channels(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: ...
+ def __init__(
+ self,
+ *,
+ session_names: collections.abc.Iterable[builtins.str] | None = ...,
+ resource_names: collections.abc.Iterable[builtins.str] | None = ...,
+ channel_lists: collections.abc.Iterable[builtins.str] | None = ...,
+ connected_channels: collections.abc.Iterable[builtins.str] | None = ...,
+ ) -> None: ...
+ def ClearField(self, field_name: typing_extensions.Literal["channel_lists", b"channel_lists", "connected_channels", b"connected_channels", "resource_names", b"resource_names", "session_names", b"session_names"]) -> None: ...
+
+global___Outputs = Outputs
diff --git a/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.py b/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.py
new file mode 100644
index 000000000..2daafffeb
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.pyi b/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.pyi
new file mode 100644
index 000000000..b13382f6a
--- /dev/null
+++ b/tests/assets/stubs/niswitch_measurement/types_pb2_grpc.pyi
@@ -0,0 +1,17 @@
+"""
+@generated by mypy-protobuf. Do not edit manually!
+isort:skip_file
+"""
+import abc
+import collections.abc
+import grpc
+import grpc.aio
+import typing
+
+_T = typing.TypeVar('_T')
+
+class _MaybeAsyncIterator(collections.abc.AsyncIterator[_T], collections.abc.Iterator[_T], metaclass=abc.ABCMeta):
+ ...
+
+class _ServicerContext(grpc.ServicerContext, grpc.aio.ServicerContext): # type: ignore
+ ...
diff --git a/tests/integration/session_management/test_nidigital_reservation.py b/tests/integration/session_management/test_nidigital_reservation.py
new file mode 100644
index 000000000..fd708bad9
--- /dev/null
+++ b/tests/integration/session_management/test_nidigital_reservation.py
@@ -0,0 +1,94 @@
+import pathlib
+from contextlib import ExitStack
+
+import pytest
+
+from ni_measurementlink_service.session_management import (
+ PinMapContext,
+ SessionManagementClient,
+)
+from tests.utilities.connection_subset import ConnectionSubset, get_connection_subset
+from tests.utilities.pin_map_client import PinMapClient
+
+
+def test___single_session_reserved___initialize_nidigital_session___creates_single_session(
+ pin_map_id: str,
+ session_management_client: SessionManagementClient,
+) -> None:
+ pin_names = ["CS"]
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0])
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_session(pin_map_context, pin_names)
+ )
+
+ session_info = stack.enter_context(reservation.initialize_nidigital_session())
+
+ assert session_info.session is not None
+ assert session_info.session_name == "DigitalPattern1"
+
+
+def test___multiple_sessions_reserved___initialize_nidigital_sessions___creates_multiple_sessions(
+ pin_map_id: str,
+ session_management_client: SessionManagementClient,
+) -> None:
+ pin_names = ["CS", "SCLK"]
+ nidigital_resource = ["DigitalPattern1", "DigitalPattern2"]
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0, 1])
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_sessions(pin_map_context, pin_names)
+ )
+
+ session_infos = stack.enter_context(reservation.initialize_nidigital_sessions())
+
+ assert all([session_info.session is not None for session_info in session_infos])
+ assert [
+ session_info.session_name == expected_resouce
+ for session_info, expected_resouce in zip(session_infos, nidigital_resource)
+ ]
+
+
+def test___session_created___get_nidigital_connection___returns_connection(
+ pin_map_id: str,
+ session_management_client: SessionManagementClient,
+) -> None:
+ pin_names = ["CS"]
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0])
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_session(pin_map_context, pin_names)
+ )
+ stack.enter_context(reservation.initialize_nidigital_session())
+
+ connection = reservation.get_nidigital_connection(pin_names[0])
+
+ assert get_connection_subset(connection) == ConnectionSubset(
+ pin_names[0], 0, "DigitalPattern1", "site0/CS"
+ )
+
+
+def test___session_created___get_nidigital_connections___returns_connections(
+ pin_map_id: str,
+ session_management_client: SessionManagementClient,
+) -> None:
+ pin_names = ["CS", "SCLK"]
+ pin_map_context = PinMapContext(pin_map_id=pin_map_id, sites=[0])
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_session(pin_map_context, pin_names)
+ )
+ stack.enter_context(reservation.initialize_nidigital_session())
+
+ connections = reservation.get_nidigital_connections(pin_names)
+
+ assert [get_connection_subset(connection) for connection in connections] == [
+ ConnectionSubset(pin_names[0], 0, "DigitalPattern1", "site0/CS"),
+ ConnectionSubset(pin_names[1], 0, "DigitalPattern1", "site0/SCLK"),
+ ]
+
+
+@pytest.fixture
+def pin_map_id(pin_map_client: PinMapClient, pin_map_directory: pathlib.Path) -> str:
+ pin_map_name = "2Digital2Group4Pin1Site.pinmap"
+ return pin_map_client.update_pin_map(pin_map_directory / pin_map_name)
diff --git a/tests/integration/session_management/test_niswitch_reservation.py b/tests/integration/session_management/test_niswitch_reservation.py
new file mode 100644
index 000000000..922d1a848
--- /dev/null
+++ b/tests/integration/session_management/test_niswitch_reservation.py
@@ -0,0 +1,94 @@
+import pathlib
+from contextlib import ExitStack
+
+import pytest
+
+from ni_measurementlink_service.session_management import (
+ PinMapContext,
+ SessionManagementClient,
+)
+from tests.utilities.connection_subset import ConnectionSubset, get_connection_subset
+from tests.utilities.pin_map_client import PinMapClient
+
+_SITE = 0
+
+
+def test___single_session_reserved___initialize_niswitch_session___creates_single_session(
+ pin_map_context: PinMapContext,
+ session_management_client: SessionManagementClient,
+) -> None:
+ relay_names = ["SiteRelay1"]
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_session(pin_map_context, relay_names)
+ )
+
+ session_info = stack.enter_context(reservation.initialize_niswitch_session())
+
+ assert session_info.session is not None
+ assert session_info.session_name == "RelayDriver1"
+
+
+def test___multiple_sessions_reserved___initialize_niswitch_sessions___creates_multiple_sessions(
+ pin_map_context: PinMapContext,
+ session_management_client: SessionManagementClient,
+) -> None:
+ relay_names = ["SiteRelay1", "SiteRelay2"]
+ niswitch_resource = ["RelayDriver1", "RelayDriver2"]
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_sessions(pin_map_context, relay_names)
+ )
+
+ session_infos = stack.enter_context(reservation.initialize_niswitch_sessions())
+
+ assert all([session_info.session is not None for session_info in session_infos])
+ assert [
+ session_info.session_name == expected_resource
+ for session_info, expected_resource in zip(session_infos, niswitch_resource)
+ ]
+
+
+def test___session_created___get_niswitch_connection___returns_connection(
+ pin_map_context: PinMapContext,
+ session_management_client: SessionManagementClient,
+) -> None:
+ relay_names = ["SiteRelay1"]
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_session(pin_map_context, relay_names)
+ )
+ stack.enter_context(reservation.initialize_niswitch_session())
+
+ connection = reservation.get_niswitch_connection(relay_names[0])
+
+ assert get_connection_subset(connection) == ConnectionSubset(
+ relay_names[0], _SITE, "RelayDriver1", "K0"
+ )
+
+
+def test___sessions_created___get_niswitch_connections___returns_connections(
+ pin_map_context: PinMapContext,
+ session_management_client: SessionManagementClient,
+) -> None:
+ relay_names = ["SiteRelay1", "SiteRelay2"]
+ with ExitStack() as stack:
+ reservation = stack.enter_context(
+ session_management_client.reserve_sessions(pin_map_context, relay_names)
+ )
+ stack.enter_context(reservation.initialize_niswitch_sessions())
+
+ connections = reservation.get_niswitch_connections(relay_names)
+
+ assert [get_connection_subset(connection) for connection in connections] == [
+ ConnectionSubset(relay_names[0], _SITE, "RelayDriver1", "K0"),
+ ConnectionSubset(relay_names[1], _SITE, "RelayDriver2", "K1"),
+ ]
+
+
+@pytest.fixture
+def pin_map_context(pin_map_client: PinMapClient, pin_map_directory: pathlib.Path) -> PinMapContext:
+ pin_map_name = "2Switch2Relay1Site.pinmap"
+ pin_map_id = pin_map_client.update_pin_map(pin_map_directory / pin_map_name)
+
+ return PinMapContext(pin_map_id=pin_map_id, sites=[_SITE])
diff --git a/tests/utilities/nidigital_measurement/NIDigitalMeasurement.serviceconfig b/tests/utilities/nidigital_measurement/NIDigitalMeasurement.serviceconfig
new file mode 100644
index 000000000..80b796820
--- /dev/null
+++ b/tests/utilities/nidigital_measurement/NIDigitalMeasurement.serviceconfig
@@ -0,0 +1,19 @@
+{
+ "services": [
+ {
+ "displayName": "NI-Digital Measurement (Py)",
+ "serviceClass": "ni.tests.NIDigitalMeasurement_Python",
+ "descriptionUrl": "",
+ "providedInterfaces": [
+ "ni.measurementlink.measurement.v1.MeasurementService",
+ "ni.measurementlink.measurement.v2.MeasurementService"
+ ],
+ "path": "start.bat",
+ "annotations": {
+ "ni/service.description": "NI-Digital MeasurementLink test service.",
+ "ni/service.collection": "NI.Tests",
+ "ni/service.tags": []
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/utilities/nidigital_measurement/Pattern.digipat b/tests/utilities/nidigital_measurement/Pattern.digipat
new file mode 100644
index 000000000..a3566238d
Binary files /dev/null and b/tests/utilities/nidigital_measurement/Pattern.digipat differ
diff --git a/tests/utilities/nidigital_measurement/PinLevels.digilevels b/tests/utilities/nidigital_measurement/PinLevels.digilevels
new file mode 100644
index 000000000..ba3551968
--- /dev/null
+++ b/tests/utilities/nidigital_measurement/PinLevels.digilevels
@@ -0,0 +1,51 @@
+
+
+
+
+
+ dc.vcc * 0.7
+ 0
+ 2.5
+ 0.5
+ 1.5 m
+ -1.5 m
+ 0
+ 0
+ HighZ
+
+
+ dc.vcc * 0.7
+ 0
+ 2.5
+ 0.5
+ 1.5 m
+ -1.5 m
+ 0
+ 0
+ HighZ
+
+
+ dc.vcc * 0.7
+ 0
+ 2.5
+ 0.5
+ 1.5 m
+ -1.5 m
+ 0
+ 0
+ HighZ
+
+
+ dc.vcc * 0.7
+ 0
+ 2.5
+ 0.5
+ 1.5 m
+ -1.5 m
+ 0
+ 0
+ HighZ
+
+
+
+
\ No newline at end of file
diff --git a/tests/utilities/nidigital_measurement/Specifications.specs b/tests/utilities/nidigital_measurement/Specifications.specs
new file mode 100644
index 000000000..8792bc803
--- /dev/null
+++ b/tests/utilities/nidigital_measurement/Specifications.specs
@@ -0,0 +1,16 @@
+
+
+
+
+
+ 1 / 1000000
+ 1 MHz
+
+
+
+
\ No newline at end of file
diff --git a/tests/utilities/nidigital_measurement/Timing.digitiming b/tests/utilities/nidigital_measurement/Timing.digitiming
new file mode 100644
index 000000000..df69bee5d
--- /dev/null
+++ b/tests/utilities/nidigital_measurement/Timing.digitiming
@@ -0,0 +1,57 @@
+
+
+
+
+
+ ac.period
+
+
+
+ 0
+ 0
+ ac.period
+
+
+ (3 * ac.period) / 4
+
+ Pattern
+
+
+
+ ac.period / 2
+ ac.period / 2
+ ac.period
+ ac.period
+
+
+ (3 * ac.period) / 4
+
+ Pattern
+
+
+
+ 0
+ 0
+ ac.period
+
+
+ (3 * ac.period) / 4
+
+ Pattern
+
+
+
+ 0
+ 0
+ ac.period
+
+
+ (3 * ac.period) / 4
+
+ Pattern
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/utilities/nidigital_measurement/__init__.py b/tests/utilities/nidigital_measurement/__init__.py
new file mode 100644
index 000000000..0cb3c709b
--- /dev/null
+++ b/tests/utilities/nidigital_measurement/__init__.py
@@ -0,0 +1,136 @@
+"""NI-Digital MeasurementLink test service."""
+import pathlib
+from itertools import groupby
+from typing import Iterable, Sequence, Tuple, Union
+
+import nidigital
+
+import ni_measurementlink_service as nims
+from ni_measurementlink_service.session_management import TypedConnection, TypedSessionInformation
+
+service_directory = pathlib.Path(__file__).resolve().parent
+measurement_service = nims.MeasurementService(
+ service_config_path=service_directory / "NIDigitalMeasurement.serviceconfig",
+ version="0.1.0.0",
+ ui_file_paths=[
+ service_directory,
+ ],
+)
+
+
+@measurement_service.register_measurement
+@measurement_service.configuration("pin_names", nims.DataType.StringArray1D, ["CS"])
+@measurement_service.configuration("multi_session", nims.DataType.Boolean, False)
+@measurement_service.output("session_names", nims.DataType.StringArray1D)
+@measurement_service.output("resource_names", nims.DataType.StringArray1D)
+@measurement_service.output("channel_lists", nims.DataType.StringArray1D)
+@measurement_service.output("connected_channels", nims.DataType.StringArray1D)
+@measurement_service.output("passing_sites", nims.DataType.Int32Array1D)
+@measurement_service.output("failing_sites", nims.DataType.Int32Array1D)
+def measure(
+ pin_names: Iterable[str],
+ multi_session: bool,
+) -> Tuple[
+ Iterable[str], Iterable[str], Iterable[str], Iterable[str], Iterable[str], Iterable[str]
+]:
+ """NI-Digital MeasurementLink test service."""
+ if multi_session:
+ with measurement_service.context.reserve_sessions(pin_names) as reservation:
+ with reservation.initialize_nidigital_sessions() as session_infos:
+ connections = reservation.get_nidigital_connections(pin_names)
+ assert all([session_info.session is not None for session_info in session_infos])
+ passing_sites, failing_sites = _burst_spi_pattern(session_infos)
+ connections_by_session = [
+ list(g) for _, g in groupby(sorted(connections, key=_key_func), key=_key_func)
+ ]
+
+ return (
+ [session_info.session_name for session_info in session_infos],
+ [session_info.resource_name for session_info in session_infos],
+ [session_info.channel_list for session_info in session_infos],
+ [
+ ", ".join(conn.channel_name for conn in conns)
+ for conns in connections_by_session
+ ],
+ list(passing_sites),
+ list(failing_sites),
+ )
+ else:
+ with measurement_service.context.reserve_session(pin_names) as reservation:
+ with reservation.initialize_nidigital_session() as session_info:
+ connection = reservation.get_nidigital_connection(list(pin_names)[0])
+ assert session_info.session is not None
+ passing_sites, failing_sites = _burst_spi_pattern([session_info])
+
+ return (
+ [session_info.session_name],
+ [session_info.resource_name],
+ [session_info.channel_list],
+ [connection.channel_name],
+ passing_sites,
+ failing_sites,
+ )
+
+
+def _burst_spi_pattern(
+ session_infos: Sequence[TypedSessionInformation[nidigital.Session]],
+) -> Tuple:
+ specifications_file_path = "Specifications.specs"
+ levels_file_path = "PinLevels.digilevels"
+ timing_file_path = "Timing.digitiming"
+ pattern_file_path = "Pattern.digipat"
+ pin_map_context = measurement_service.context.pin_map_context
+ selected_sites_string = ",".join(f"site{i}" for i in pin_map_context.sites or [])
+
+ passing_sites_list, failing_sites_list = [], []
+ for session_info in session_infos:
+ session = session_info.session
+ selected_sites = session.sites[selected_sites_string]
+
+ if not session_info.session_exists:
+ session.load_pin_map(pin_map_context.pin_map_id)
+ session.load_specifications_levels_and_timing(
+ str(_resolve_relative_path(service_directory, specifications_file_path)),
+ str(_resolve_relative_path(service_directory, levels_file_path)),
+ str(_resolve_relative_path(service_directory, timing_file_path)),
+ )
+ session.load_pattern(
+ str(_resolve_relative_path(service_directory, pattern_file_path)),
+ )
+
+ levels_file_name = pathlib.Path(levels_file_path).stem
+ timing_file_name = pathlib.Path(timing_file_path).stem
+
+ for session_info in session_infos:
+ selected_sites = session_info.session.sites[selected_sites_string]
+ selected_sites.apply_levels_and_timing(levels_file_name, timing_file_name)
+
+ for session_info in session_infos:
+ selected_sites = session_info.session.sites[selected_sites_string]
+ selected_sites.burst_pattern(start_label="SPI_Pattern", wait_until_done=False)
+
+ for session_info in session_infos:
+ selected_sites = session_info.session.sites[selected_sites_string]
+ session_info.session.wait_until_done()
+ site_pass_fail = selected_sites.get_site_pass_fail()
+ passing_sites = [site for site, pass_fail in site_pass_fail.items() if pass_fail]
+ failing_sites = [site for site, pass_fail in site_pass_fail.items() if not pass_fail]
+ passing_sites_list.extend(passing_sites)
+ failing_sites_list.extend(failing_sites)
+ session.selected_function = nidigital.SelectedFunction.DISCONNECT
+
+ return (passing_sites_list, failing_sites_list)
+
+
+def _resolve_relative_path(
+ directory_path: pathlib.Path, file_path: Union[str, pathlib.Path]
+) -> pathlib.Path:
+ file_path = pathlib.Path(file_path)
+ if file_path.is_absolute():
+ return file_path
+ else:
+ return (directory_path / file_path).resolve()
+
+
+def _key_func(conn: TypedConnection[nidigital.Session]) -> str:
+ return conn.session_info.session_name
diff --git a/tests/utilities/niswitch_measurement/NISwitchMeasurement.serviceconfig b/tests/utilities/niswitch_measurement/NISwitchMeasurement.serviceconfig
new file mode 100644
index 000000000..3e3cd5afd
--- /dev/null
+++ b/tests/utilities/niswitch_measurement/NISwitchMeasurement.serviceconfig
@@ -0,0 +1,19 @@
+{
+ "services": [
+ {
+ "displayName": "NI-SWITCH Measurement (Py)",
+ "serviceClass": "ni.tests.NISwitchMeasurement_Python",
+ "descriptionUrl": "",
+ "providedInterfaces": [
+ "ni.measurementlink.measurement.v1.MeasurementService",
+ "ni.measurementlink.measurement.v2.MeasurementService"
+ ],
+ "path": "start.bat",
+ "annotations": {
+ "ni/service.description": "NI-SWITCH MeasurementLink test service.",
+ "ni/service.collection": "NI.Tests",
+ "ni/service.tags": []
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/utilities/niswitch_measurement/__init__.py b/tests/utilities/niswitch_measurement/__init__.py
new file mode 100644
index 000000000..f31e8d68e
--- /dev/null
+++ b/tests/utilities/niswitch_measurement/__init__.py
@@ -0,0 +1,69 @@
+"""NI-SWITCH MeasurementLink test service."""
+import pathlib
+from typing import Iterable, Sequence, Tuple
+
+import niswitch
+
+import ni_measurementlink_service as nims
+from ni_measurementlink_service.session_management import TypedSessionInformation
+
+service_directory = pathlib.Path(__file__).resolve().parent
+measurement_service = nims.MeasurementService(
+ service_config_path=service_directory / "NISwitchMeasurement.serviceconfig",
+ version="0.1.0.0",
+ ui_file_paths=[
+ service_directory,
+ ],
+)
+
+
+@measurement_service.register_measurement
+@measurement_service.configuration("relay_names", nims.DataType.StringArray1D, ["SiteRelay1"])
+@measurement_service.configuration("multi_session", nims.DataType.Boolean, False)
+@measurement_service.output("session_names", nims.DataType.StringArray1D)
+@measurement_service.output("resource_names", nims.DataType.StringArray1D)
+@measurement_service.output("channel_lists", nims.DataType.StringArray1D)
+@measurement_service.output("connected_channels", nims.DataType.StringArray1D)
+def measure(
+ relay_names: Iterable[str],
+ multi_session: bool,
+) -> Tuple[Iterable[str], Iterable[str], Iterable[str], Iterable[str]]:
+ """NI-SWITCH MeasurementLink test service."""
+ if multi_session:
+ with measurement_service.context.reserve_sessions(relay_names) as reservation:
+ with reservation.initialize_niswitch_sessions() as session_infos:
+ connections = reservation.get_niswitch_connections(relay_names)
+ assert all([session_info.session is not None for session_info in session_infos])
+ _control_relays(session_infos)
+
+ return (
+ [session_info.session_name for session_info in session_infos],
+ [session_info.resource_name for session_info in session_infos],
+ [session_info.channel_list for session_info in session_infos],
+ [connection.channel_name for connection in connections],
+ )
+ else:
+ with measurement_service.context.reserve_session(relay_names) as reservation:
+ with reservation.initialize_niswitch_session() as session_info:
+ connection = reservation.get_niswitch_connection(list(relay_names)[0])
+ assert session_info.session is not None
+ _control_relays([session_info])
+
+ return (
+ [session_info.session_name],
+ [session_info.resource_name],
+ [session_info.channel_list],
+ [connection.channel_name],
+ )
+
+
+def _control_relays(
+ session_infos: Sequence[TypedSessionInformation[niswitch.Session]],
+) -> None:
+ for session_info in session_infos:
+ session_info.session.relay_control(
+ session_info.channel_list, niswitch.enums.RelayAction.CLOSE
+ )
+
+ for session_info in session_infos:
+ session_info.session.wait_for_debounce()