예제 #1
0
    def __init__(
        self,
        domain,
        log,
        standardpath,
        externalpath,
        next_visit_callback=None,
        next_visit_canceled_callback=None,
        queue_callback=None,
        script_callback=None,
        min_sal_index=MIN_SAL_INDEX,
        max_sal_index=salobj.MAX_SAL_INDEX,
        verbose=False,
    ):
        if not os.path.isdir(standardpath):
            raise ValueError(f"No such dir standardpath={standardpath}")
        if not os.path.isdir(externalpath):
            raise ValueError(f"No such dir externalpath={externalpath}")

        for arg, arg_name in (
            (next_visit_callback, "next_visit_callback"),
            (next_visit_canceled_callback, "next_visit_canceled_callback"),
            (queue_callback, "queue_callback"),
            (script_callback, "script_callback"),
        ):
            if arg is not None and not inspect.iscoroutinefunction(arg):
                raise TypeError(
                    f"{arg_name}={arg} must be a coroutine or None")

        self.domain = domain
        self.log = log.getChild("QueueModel")
        self.standardpath = os.path.abspath(standardpath)
        self.externalpath = os.path.abspath(externalpath)
        self.next_visit_callback = next_visit_callback
        self.next_visit_canceled_callback = next_visit_canceled_callback
        self.queue_callback = queue_callback
        self.script_callback = script_callback
        self.min_sal_index = min_sal_index
        self.max_sal_index = max_sal_index
        self.verbose = verbose
        # queue of ScriptInfo instances
        self.queue = collections.deque()
        self.history = collections.deque(maxlen=MAX_HISTORY)
        self.current_script = None
        self._running = True
        self._enabled = False
        self._index_generator = index_generator(imin=min_sal_index,
                                                imax=max_sal_index)
        self._scripts_being_stopped = set()
        # use index=0 so we get messages for all scripts
        self.remote = salobj.Remote(domain=domain,
                                    name="Script",
                                    index=0,
                                    evt_max_history=0)
        self.remote.evt_metadata.callback = self._script_metadata_callback
        self.remote.evt_state.callback = self._script_state_callback
        if self.verbose:
            self.remote.evt_logMessage.callback = self._log_message_callback
        self.start_task = self.remote.start_task
예제 #2
0
    def __init__(self, models, raw_telemetry, parameters=None, log=None):

        self.observing_list_dict = dict()

        self.index_gen = index_generator()

        self.validator = jsonschema.Draft7Validator(self.schema())

        super().__init__(models, raw_telemetry, parameters, log=log)
예제 #3
0
    def __init__(
        self,
        *,
        salinfo: SalInfo,
        attr_name: str,
        min_seq_num: typing.Optional[int] = 1,
        max_seq_num: int = MAX_SEQ_NUM,
        initial_seq_num: typing.Optional[int] = None,
    ) -> None:
        super().__init__(salinfo=salinfo, attr_name=attr_name)
        self.isopen = True
        self.min_seq_num = min_seq_num  # record for unit tests
        self.max_seq_num = max_seq_num
        if min_seq_num is None:
            self._seq_num_generator: typing.Optional[
                typing.Generator[int, None, None]
            ] = None
        else:
            self._seq_num_generator = utils.index_generator(
                imin=min_seq_num, imax=max_seq_num, i0=initial_seq_num
            )
        # Command topics use a different partition name than
        # all other topics, including ackcmd, and the partition name
        # is part of the publisher and subscriber.
        # This split allows us to create just one subscriber and one publisher
        # for each Controller or Remote:
        # `Controller` only needs a cmd_subscriber and data_publisher,
        # `Remote` only needs a cmd_publisher and data_subscriber.
        if attr_name.startswith("cmd_"):
            publisher = salinfo.cmd_publisher
        else:
            publisher = salinfo.data_publisher
        self._writer = publisher.create_datawriter(self._topic, self.qos_set.writer_qos)
        self._has_data = False
        self._data = self.DataType()
        # Record which field names are float, double or array of either,
        # to make it easy to compare float fields with nan equal.
        self._float_field_names = set()
        for name, value in self._data.get_vars().items():
            if isinstance(value, list):
                # In our SAL schemas arrays are fixed length
                # and must contain at least one element.
                elt = value[0]
            else:
                elt = value
            if isinstance(elt, float):
                self._float_field_names.add(name)

        salinfo.add_writer(self)
예제 #4
0
import numpy as np
import pytest

from lsst.ts import salobj
from lsst.ts import utils

# Long enough to perform any reasonable operation
# including starting a CSC or loading a script (seconds)
STD_TIMEOUT = 60

HISTORY_TIMEOUT_NAME = "LSST_DDS_HISTORYSYNC"
INITIAL_HISTORY_TIMEOUT = os.environ.get(HISTORY_TIMEOUT_NAME, None)

np.random.seed(47)

index_gen = utils.index_generator()


class RemoteTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_constructor_include_exclude(self) -> None:
        """Test the include and exclude arguments for salobj.Remote."""

        index = next(index_gen)
        async with salobj.Domain() as domain:
            salinfo = salobj.SalInfo(domain=domain, name="Test", index=index)

            # all possible expected topic names
            all_command_names = set(salinfo.command_names)
            all_event_names = set(salinfo.event_names)
            all_telemetry_names = set(salinfo.telemetry_names)
예제 #5
0
    async def _slew_to(
        self,
        slew_cmd: typing.Any,
        slew_timeout: float,
        offset_cmd: typing.Any = None,
        stop_before_slew: bool = True,
        wait_settle: bool = True,
        check: typing.Any = None,
    ) -> None:
        """Encapsulate "slew" activities.

        Parameters
        ----------
        slew_cmd : `coro`
            One of the slew commands from the mtptg remote. Command need to be
            setup before calling this method.
        slew_timeout : `float`
            Expected slewtime (seconds).
        stop_before_slew : `bool`
            Before starting a slew, send a stop target event?
        wait_settle : `bool`
            Once the telescope report in position, add an additional wait
            before returning? The time is controlled by the internal variable
            `self.tel_settle_time`.
        check : `types.SimpleNamespace` or `None`, optional
            Override internal `check` attribute with a user-provided one.
            By default (`None`) use internal attribute.
        """

        assert self._dome_az_in_position is not None

        _check = self.check if check is None else check

        ccw_following = await self.rem.mtmount.evt_cameraCableWrapFollowing.aget(
            timeout=self.fast_timeout)

        if not ccw_following.enabled:
            # TODO DM-32545: Restore exception in slew method if dome
            # following is disabled.
            self.log.warning(
                "Camera cable wrap following disabled in MTMount.")

        if stop_before_slew:
            try:
                await self.stop_tracking()

            except Exception:
                self.log.exception("Error stop tracking.")

        try:
            # TODO DM-32546: Remove work around to rotator trajectory
            # problem that cannot complete 2 subsequent moves. The work
            # around consist of sending a move command to the rotator
            # current position then stopping, thus resetting the
            # trajectory.
            await self.rem.mtrotator.cmd_stop.start(timeout=self.fast_timeout)

            self.log.debug(
                f"Wait {self.fast_timeout}s for rotator to settle down.")
            await asyncio.sleep(self.fast_timeout)

            rotator_position = await self.rem.mtrotator.tel_rotation.aget(
                timeout=self.fast_timeout)

            rotator_reset_position = rotator_position.actualPosition + (
                0.1 if rotator_position.actualPosition < 0.0 else -0.1)

            self.log.debug(
                "Workaround for rotator trajectory problem. "
                f"Moving rotator to its current position: {rotator_reset_position:0.2f}"
            )

            await self.move_rotator(position=rotator_reset_position)
        except Exception:
            self.log.exception("Error trying to reset rotator position.")

        track_id = next(self.track_id_gen)

        try:
            current_target = await self.rem.mtmount.evt_target.next(
                flush=True, timeout=self.fast_timeout)
            if track_id <= current_target.trackId:
                self.track_id_gen = utils.index_generator(
                    current_target.trackId + 1)
                track_id = next(self.track_id_gen)

        except asyncio.TimeoutError:
            pass

        slew_cmd.data.trackId = track_id

        self.log.debug("Sending slew command.")

        if stop_before_slew:
            self.flush_offset_events()
            self.rem.mtrotator.evt_inPosition.flush()

        await slew_cmd.start(timeout=slew_timeout)
        self._dome_az_in_position.clear()
        if offset_cmd is not None:
            await offset_cmd.start(timeout=self.fast_timeout)

        self.log.debug("Scheduling check coroutines")

        self.scheduled_coro.append(
            asyncio.create_task(
                self.wait_for_inposition(timeout=slew_timeout,
                                         wait_settle=wait_settle)))
        self.scheduled_coro.append(asyncio.create_task(
            self.monitor_position()))

        for comp in self.components_attr:
            if getattr(_check, comp):
                getattr(self.rem, comp).evt_summaryState.flush()
                self.scheduled_coro.append(
                    asyncio.create_task(self.check_component_state(comp)))

        await self.process_as_completed(self.scheduled_coro)
class RemoteGroupTestCase(metaclass=abc.ABCMeta):
    """Base class for testing groups of CSCs.

    Subclasses must:

    * Inherit both from this and `asynctest.TestCase`.
    * Override `basic_make_group` to make the script and any other
      controllers, remotes and such, and return a list of scripts,
      controllers and remotes that you made.

    A typical test will look like this:

        async def test_something(self):
            async with make_group():
                # ... test something
    """

    _index_iter = utils.index_generator()

    def run(self, result: typing.Any = None) -> None:
        """Override `run` to set a random LSST_DDS_PARTITION_PREFIX
        and set LSST_SITE=test for every test.

        https://stackoverflow.com/a/11180583
        """
        salobj.set_random_lsst_dds_partition_prefix()
        with utils.modify_environ(LSST_SITE="test"):
            super().run(result)  # type: ignore

    @abc.abstractmethod
    async def basic_make_group(self,
                               usage: typing.Optional[int] = None) -> None:
        """Make a group as self.group.

        Make all other controllers and remotes, as well
        and return a list of the items made.

        Returns
        -------
        items : `List` [``any``]
            Controllers, Remotes and Group, or any other items
            for which to initially wait for ``item.start_task``
            and finally wait for ``item.close()``.

        Notes
        -----
        This is a coroutine in the unlikely case that you might
        want to wait for something.
        """
        raise NotImplementedError()

    async def close(self) -> None:
        """Optional cleanup before closing."""
        pass

    @contextlib.asynccontextmanager
    async def make_group(
        self,
        timeout: float = MAKE_TIMEOUT,
        usage: typing.Optional[int] = None,
        verbose: bool = False,
    ) -> typing.AsyncGenerator:
        """Create a Group.

        The group is accessed as ``self.group``.

        Parameters
        ----------
        timeout : `float`
            Timeout (sec) for waiting for ``item.start_task`` and
            ``item.close()`` for each item returned by `basic_make_script`,
            and `self.close`.
        usage: `int`
            Combined enumeration with intended usage.
        verbose : `bool`
            Log data? This can be helpful for setting ``timeout``.
        """
        items_to_await = await self.wait_for(
            self.basic_make_group(usage),
            timeout=timeout,
            description="self.basic_make_group()",
            verbose=verbose,
        )
        try:
            await self.wait_for(
                asyncio.gather(*[
                    item.start_task for item in items_to_await
                    if item.start_task is not None
                ]),
                timeout=timeout,
                description=f"item.start_task for {len(items_to_await)} items",
                verbose=verbose,
            )
            yield
        finally:
            await self.wait_for(
                self.close(),
                timeout=timeout,
                description="self.close()",
                verbose=verbose,
            )
            await self.wait_for(
                asyncio.gather(*[item.close() for item in items_to_await]),
                timeout=timeout,
                description=f"item.close() for {len(items_to_await)} items",
                verbose=verbose,
            )

    async def wait_for(self, coro: typing.Awaitable, timeout: float,
                       description: str, verbose: bool) -> typing.Any:
        """A wrapper around asyncio.wait_for that prints timing information.

        Parameters
        ----------
        coro : ``awaitable``
            Coroutine or task to await.
        timeout : `float`
            Timeout (seconds)
        description : `str`
            Description of what is being awaited.
        verbose : `bool`
            If True then print a message before waiting
            and another after that includes how long it waited.
            If False only print a message if the wait times out.
        """
        t0 = time.monotonic()
        if verbose:
            print(f"wait for {description}")
        try:
            result = await asyncio.wait_for(coro, timeout=timeout)
        except asyncio.TimeoutError:
            dt = time.monotonic() - t0
            print(f"{description} timed out after {dt:0.1f} seconds")
            raise
        if verbose:
            dt = time.monotonic() - t0
            print(f"{description} took {dt:0.1f} seconds")
        return result
class BaseScriptTestCase(metaclass=abc.ABCMeta):
    """Base class for Script tests.

    Subclasses must:

    * Inherit both from this and `unittest.IsolatedAsyncioTestCase`.
    * Override `basic_make_script` to make the script and any other
      controllers, remotes and such, and return a list of scripts,
      controllers and remotes that you made.

    A typical test will look like this:

        async def test_something(self):
            async with make_script():
                await self.configure_script(...)
                # ... test the results of configuring the script (self.script)

                await self.run_script()  # (unless only testing configuration)
                # ... test the results of running the script
    """

    _index_iter = utils.index_generator()

    @abc.abstractmethod
    async def basic_make_script(self, index):
        """Make a script as self.script.

        Make all other controllers and remotes, as well
        and return a list of the items made.

        Parameters
        ----------
        index : `int`
            The SAL index of the script.

        Returns
        -------
        items : `List` [``any``]
            Controllers, Remotes and Script, or any other items
            for which to initially wait for ``item.start_task``
            and finally wait for ``item.close()``.

        Notes
        -----
        This is a coroutine in the unlikely case that you might
        want to wait for something.
        """
        raise NotImplementedError()

    async def close(self):
        """Optional cleanup before closing the scripts and etc."""
        pass

    async def check_executable(self, script_path):
        """Check that an executable script can be launched.

        Parameter
        ---------
        script_path : `str`
            Full path to script.
        """
        salobj.set_random_lsst_dds_partition_prefix()

        index = self.next_index()

        script_path = pathlib.Path(script_path).resolve()

        assert script_path.is_file()

        async with salobj.Domain() as domain, salobj.Remote(
                domain=domain, name="Script", index=index) as remote:

            initial_path = os.environ["PATH"]
            try:
                os.environ["PATH"] = str(
                    script_path.parent) + ":" + initial_path
                process = await asyncio.create_subprocess_exec(
                    str(script_path), str(index))

                state = await remote.evt_state.next(flush=False,
                                                    timeout=MAKE_TIMEOUT)
                assert state.state == Script.ScriptState.UNCONFIGURED
            finally:
                process.terminate()
                os.environ["PATH"] = initial_path

    async def configure_script(self, **kwargs):
        """Configure the script and set the group ID (if using ts_salobj
        4.5 or later).

        Sets the script state to UNCONFIGURED.
        This allows you to call configure_script multiple times.

        Parameters
        ----------
        kwargs : `dict`
            Keyword arguments for configuration.

        Returns
        -------
        config : `types.SimpleNamespace`
            ``kwargs`` expressed as a SimpleNamespace.
            This is provided as a convenience, to avoid boilerplate
            and duplication in your unit tests. The data is strictly
            based on the input arguments; it has nothing to do
            with the script.
        """
        await self.script.set_state(Script.ScriptState.UNCONFIGURED)
        config = types.SimpleNamespace(**kwargs)
        config_data = self.script.cmd_configure.DataType()
        if kwargs:
            config_data.config = yaml.safe_dump(kwargs)
        await self.script.do_configure(config_data)
        assert self.script.state.state == Script.ScriptState.CONFIGURED
        if hasattr(self.script, "cmd_setGroupId"):
            group_id_data = self.script.cmd_setGroupId.DataType(
                groupId=astropy.time.Time.now().isot)
            await self.script.do_setGroupId(group_id_data)
        return config

    @contextlib.asynccontextmanager
    async def make_script(self,
                          log_level=logging.INFO,
                          timeout=MAKE_TIMEOUT,
                          verbose=False):
        """Create a Script.

        The script is accessed as ``self.script``.

        Parameters
        ----------
        name : `str`
            Name of SAL component.
        log_level : `int` (optional)
            Logging level, such as `logging.INFO`.
        timeout : `float`
            Timeout (sec) for waiting for ``item.start_task`` and
            ``item.close()`` for each item returned by `basic_make_script`,
            and `self.close`.
        verbose : `bool`
            Log data? This can be helpful for setting ``timeout``.
        """
        salobj.set_random_lsst_dds_partition_prefix()

        items_to_await = await self.wait_for(
            self.basic_make_script(index=self.next_index()),
            timeout=timeout,
            description="self.basic_make_script()",
            verbose=verbose,
        )
        try:
            await self.wait_for(
                asyncio.gather(*[item.start_task for item in items_to_await]),
                timeout=timeout,
                description=f"item.start_task for {len(items_to_await)} items",
                verbose=verbose,
            )
            yield
        finally:
            await self.wait_for(
                self.close(),
                timeout=timeout,
                description="self.close()",
                verbose=verbose,
            )
            await self.wait_for(
                asyncio.gather(*[item.close() for item in items_to_await]),
                timeout=timeout,
                description=f"item.close() for {len(items_to_await)} items",
                verbose=verbose,
            )

    def next_index(self):
        return next(self._index_iter)

    async def run_script(self):
        """Run the script.

        Requires that the script be configured and the group ID set
        (if using ts_salobj 4.5 or later).
        """
        run_data = self.script.cmd_run.DataType()
        await self.script.do_run(run_data)
        await self.script.done_task
        assert self.script.state.state == Script.ScriptState.DONE

    async def wait_for(self, coro, timeout, description, verbose):
        """A wrapper around asyncio.wait_for that prints timing information.

        Parameters
        ----------
        coro : ``awaitable``
            Coroutine or task to await.
        timeout : `float`
            Timeout (seconds)
        description : `str`
            Description of what is being awaited.
        verbose : `bool`
            If True then print a message before waiting
            and another after that includes how long it waited.
            If False only print a message if the wait times out.
        """
        t0 = time.monotonic()
        if verbose:
            print(f"wait for {description}")
        try:
            result = await asyncio.wait_for(coro, timeout=timeout)
        except asyncio.TimeoutError:
            dt = time.monotonic() - t0
            print(f"{description} timed out after {dt:0.1f} seconds")
            raise
        if verbose:
            dt = time.monotonic() - t0
            print(f"{description} took {dt:0.1f} seconds")
        return result
예제 #8
0
import json
from lsst.ts import salobj
from lsst.ts.utils import index_generator
from commander_utils import NumpyEncoder

index_gen = index_generator()


async def test_successful_command(client):
    # Arrange
    # setup dds / csc
    salobj.set_random_lsst_dds_partition_prefix()
    next(index_gen)
    csc = salobj.TestCsc(index=1, config_dir=None, initial_state=salobj.State.ENABLED)
    await csc.start_task

    # build data
    cmd_data = csc.make_random_scalars_dict()
    data = json.loads(
        json.dumps(
            {
                "csc": "Test",
                "salindex": 1,
                "cmd": "cmd_setScalars",
                "params": cmd_data,
            },
            cls=NumpyEncoder,
        )
    )

    # Act
예제 #9
0
class BaseCscTestCase(metaclass=abc.ABCMeta):
    """Base class for CSC tests.

    Subclasses must:

    * Inherit both from this and `unittest.IsolatedAsyncioTestCase`.
    * Override `basic_make_csc` to return a CSC.

    Also we suggest:

    * Add a method ``test_standard_state_transitions`` which calls
      `check_standard_state_transitions`.
    * Add a method ``test_bin_script`` which calls `check_bin_script`,
      assuming you have a binary script to run your CSC.
    """

    _index_iter = utils.index_generator()

    def run(self, result: typing.Any = None) -> None:  # type: ignore
        """Set a random LSST_DDS_PARTITION_PREFIX
        and set LSST_SITE=test for every test.

        Unlike setUp, a user cannot forget to override this.
        (This is also a good place for context managers).
        """
        testutils.set_random_lsst_dds_partition_prefix()
        # set LSST_SITE using os.environ instead of utils.modify_environ
        # so that check_bin_script works.
        os.environ["LSST_SITE"] = "test"
        super().run(result)  # type: ignore

    @abc.abstractmethod
    def basic_make_csc(
        self,
        initial_state: typing.Union[sal_enums.State, int],
        config_dir: typing.Union[str, pathlib.Path, None],
        simulation_mode: int,
        **kwargs: typing.Any,
    ) -> base_csc.BaseCsc:
        """Make and return a CSC.

        Parameters
        ----------
        initial_state : `lsst.ts.salobj.State` or `int`
            The initial state of the CSC.
        config_dir : `str` or `pathlib.Path` or `None`
            Directory of configuration files, or None for the standard
            configuration directory (obtained from
            `ConfigureCsc._get_default_config_dir`).
        simulation_mode : `int`
            Simulation mode.
        kwargs : `dict`
            Extra keyword arguments, if needed.
        """
        raise NotImplementedError()

    def next_index(self) -> int:
        """Get the next SAL index."""
        return next(self._index_iter)

    @contextlib.asynccontextmanager
    async def make_csc(
        self,
        initial_state: sal_enums.State = sal_enums.State.STANDBY,
        config_dir: typing.Union[str, pathlib.Path, None] = None,
        simulation_mode: int = 0,
        log_level: typing.Optional[int] = None,
        timeout: float = STD_TIMEOUT,
        **kwargs: typing.Any,
    ) -> typing.AsyncGenerator[None, None]:
        """Create a CSC and remote and wait for them to start,
        after setting a random $LSST_DDS_PARTITION_PREFIX.

        The csc is accessed as ``self.csc`` and the remote as ``self.remote``.

        Reads and checks all but the last ``summaryState`` event during
        startup.

        Parameters
        ----------
        name : `str`
            Name of SAL component.
        initial_state : `lsst.ts.salobj.State` or `int`, optional
            The initial state of the CSC. Defaults to STANDBY.
        config_dir : `str`, optional
            Directory of configuration files, or `None` (the default)
            for the standard configuration directory (obtained from
            `ConfigureCsc._get_default_config_dir`).
        simulation_mode : `int`, optional
            Simulation mode. Defaults to 0 because not all CSCs support
            simulation. However, tests of CSCs that support simulation
            will almost certainly want to set this nonzero.
        log_level : `int` or `None`, optional
            Logging level, such as `logging.INFO`.
            If `None` then do not set the log level, leaving the default
            behavior of `SalInfo`: increase the log level to INFO.
        timeout : `float`, optional
            Time limit for the CSC to start (seconds).
        **kwargs : `dict`, optional
            Extra keyword arguments for `basic_make_csc`.
            For a configurable CSC this may include ``override``,
            especially if ``initial_state`` is DISABLED or ENABLED.
        """
        # Redundant with setUp, but preserve in case a subclass
        # forgets to call super().setUp()
        testutils.set_random_lsst_dds_partition_prefix()
        items_to_close: typing.List[typing.Union[base_csc.BaseCsc,
                                                 Remote]] = []
        try:
            self.csc = self.basic_make_csc(
                initial_state=initial_state,
                config_dir=config_dir,
                simulation_mode=simulation_mode,
                **kwargs,
            )
            items_to_close.append(self.csc)
            self.remote = Remote(
                domain=self.csc.domain,
                name=self.csc.salinfo.name,
                index=self.csc.salinfo.index,
            )
            items_to_close.append(self.remote)
            if log_level is not None:
                self.csc.log.setLevel(log_level)

            await asyncio.wait_for(
                asyncio.gather(self.csc.start_task, self.remote.start_task),
                timeout=timeout,
            )

            if initial_state != self.csc.default_initial_state:
                # Check all expected summary states expect the final state.
                # That is omitted for backwards compatibility.
                expected_states = get_expected_summary_states(
                    initial_state=self.csc.default_initial_state,
                    final_state=initial_state,
                )[:-1]
                for state in expected_states:
                    await self.assert_next_summary_state(state)

            yield
        except Exception as e:
            print(f"BaseCscTestCase.make_csc failed: {e!r}")
            raise
        finally:
            for item in items_to_close:
                await item.close()

    async def assert_next_summary_state(
        self,
        state: sal_enums.State,
        flush: bool = False,
        timeout: float = STD_TIMEOUT,
        remote: typing.Optional[Remote] = None,
    ) -> None:
        """Wait for and check the next ``summaryState`` event.

        Parameters
        ----------
        state : `lsst.ts.salobj.State` or `int`
            Desired summary state.
        flush : `bool`, optional
            Flush the read queue before waiting?
        timeout : `float`, optional
            Time limit for getting the data sample (sec).
        remote : `Remote`, optional
            Remote to use; ``self.remote`` if None.
        """
        if remote is None:
            remote = self.remote
        await self.assert_next_sample(
            topic=remote.evt_summaryState,  # type: ignore
            flush=flush,
            timeout=timeout,
            summaryState=state,
        )

    async def assert_next_sample(
        self,
        topic: ReadTopic,
        flush: bool = False,
        timeout: float = STD_TIMEOUT,
        **kwargs: typing.Any,
    ) -> type_hints.BaseMsgType:
        """Wait for the next data sample for the specified topic,
        check specified fields for equality, and return the data.

        Parameters
        ----------
        topic : `topics.ReadTopic`
            Topic to read, e.g. ``remote.evt_logMessage``.
        flush : `bool`, optional
            Flush the read queue before waiting?
        timeout : `double`, optional
            Time limit for getting the data sample (sec).
        kwargs : `dict`
            Dict of field_name: expected_value
            The specified fields will be checked for equality.

        Returns
        -------
        data : topic data type
            The data read.
        """
        data = await topic.next(flush=flush, timeout=timeout)
        for field_name, expected_value in kwargs.items():
            read_value = getattr(data, field_name, None)
            if read_value is None:
                raise AssertionError(
                    f"No such field {field_name} in topic {topic}")
            if isinstance(expected_value, enum.IntEnum):
                try:
                    read_value = type(expected_value)(read_value)
                except Exception:
                    pass
            assert (
                read_value == expected_value
            ), f"Failed on field {field_name}: read {read_value!r} != expected {expected_value!r}"
        return data

    async def check_bin_script(
        self,
        name: str,
        index: int,
        exe_name: str,
        default_initial_state: sal_enums.State = sal_enums.State.STANDBY,
        initial_state: typing.Optional[sal_enums.State] = None,
        override: typing.Optional[str] = None,
        cmdline_args: typing.Sequence[str] = (),
        timeout: float = STD_TIMEOUT,
    ) -> None:
        """Test running the CSC command line script.

        Parameters
        ----------
        name : `str`
            Name of SAL component, e.g. "Rotator"
        index : `int` or `None`
            SAL index of component.
        exe_name : `str`
            Name of executable, e.g. "run_rotator.py"
        default_initial_state : `lsst.ts.salobj.State`, optional
            The default initial state of the CSC.
            Ignored unless `initial_state` is None.
        initial_state : `lsst.ts.salobj.State` or `int` or `None`, optional
            The desired initial state of the CSC; used to specify
            the ``--state`` command-argument.
        override : `str` or `None`, optional
            Value for the ``--override`` command-line argument,
            which is omitted if override is None.
        cmdline_args : `List` [`str`]
            Additional command-line arguments, such as "--simulate".
        timeout : `float`, optional
            Time limit for the CSC to start and output
            the summaryState event.
        """
        # Redundant with setUp, but preserve in case a subclass
        # forgets to call super().setUp()
        testutils.set_random_lsst_dds_partition_prefix()
        exe_path = shutil.which(exe_name)
        if exe_path is None:
            raise AssertionError(
                f"Could not find bin script {exe_name}; did you setup or install this package?"
            )

        args = [exe_name]
        if index not in (None, 0):
            args += [str(index)]
        if initial_state is None:
            expected_states = [default_initial_state]
        else:
            args += ["--state", initial_state.name.lower()]
            expected_states = get_expected_summary_states(
                initial_state=default_initial_state,
                final_state=initial_state,
            )
        if override is not None:
            args += ["--override", override]
        args += cmdline_args

        async with Domain() as domain, Remote(domain=domain,
                                              name=name,
                                              index=index) as self.remote:
            print("check_bin_script running:", " ".join(args))
            process = await asyncio.create_subprocess_exec(
                *args,
                stderr=subprocess.PIPE,
            )
            try:
                for state in expected_states:
                    await self.assert_next_summary_state(state,
                                                         timeout=timeout)
                if override:
                    # The override should appear in
                    # evt_configurationApplied.configurations
                    data = await self.assert_next_sample(
                        topic=self.remote.
                        evt_configurationApplied,  # type: ignore
                    )
                    assert override in data.configurations  # type: ignore
            finally:
                if process.returncode is None:
                    process.terminate()
                    await asyncio.wait_for(process.wait(), timeout=STD_TIMEOUT)
                else:
                    print("Warning: subprocess had already quit.")
                    try:
                        assert process.stderr is not None  # make mypy happy
                        errbytes = await process.stderr.read()
                        print("Subprocess stderr: ", errbytes.decode())
                    except Exception as e:
                        print(f"Could not read subprocess stderr: {e}")

    async def check_standard_state_transitions(
        self,
        enabled_commands: typing.Sequence[str],
        skip_commands: typing.Optional[typing.Sequence[str]] = None,
        override: str = "",
        timeout: float = STD_TIMEOUT,
    ) -> None:
        """Test standard CSC state transitions.

        Parameters
        ----------
        enabled_commands : `List` [`str`]
            List of CSC-specific commands that are valid in the enabled state.
            Need not include the standard commands, which are "disable"
            and "setLogLevel" (which is valid in any state).
        skip_commands : `List` [`str`] or `None`, optional
            List of commands to skip.
        override : `str`, optional
            Configuration override file to apply when the CSC is taken
            from state `State.STANDBY` to `State.DISABLED`.
        timeout : `float`, optional
            Time limit for state transition commands (seconds).

        Notes
        -----
        ``timeout`` is only used for state transition commands that
        are expected to succceed. ``STD_TIMEOUT`` is used for things
        that should happen quickly:

        * Commands that should fail, due to the CSC being in the wrong state.
        * The ``summaryState`` event after each state transition:
        """
        enabled_commands = tuple(enabled_commands)
        skip_commands = tuple(skip_commands) if skip_commands else ()

        # Start in STANDBY state.
        assert self.csc.summary_state == sal_enums.State.STANDBY
        await self.assert_next_summary_state(sal_enums.State.STANDBY)
        await self.check_bad_commands(
            good_commands=("start", "exitControl", "setAuthList",
                           "setLogLevel") + skip_commands)

        # Send start; new state is DISABLED.
        await self.remote.cmd_start.set_start(  # type: ignore
            configurationOverride=override, timeout=timeout)
        assert self.csc.summary_state == sal_enums.State.DISABLED
        await self.assert_next_summary_state(sal_enums.State.DISABLED)
        await self.check_bad_commands(
            good_commands=("enable", "standby", "setAuthList", "setLogLevel") +
            skip_commands)

        # Send enable; new state is ENABLED.
        await self.remote.cmd_enable.start(timeout=timeout)  # type: ignore
        assert self.csc.summary_state == sal_enums.State.ENABLED
        await self.assert_next_summary_state(sal_enums.State.ENABLED)
        all_enabled_commands = tuple(
            sorted(
                set(("disable", "setAuthList", "setLogLevel"))
                | set(enabled_commands)))
        await self.check_bad_commands(good_commands=all_enabled_commands +
                                      skip_commands)

        # Send disable; new state is DISABLED.
        await self.remote.cmd_disable.start(timeout=timeout)  # type: ignore
        assert self.csc.summary_state == sal_enums.State.DISABLED
        await self.assert_next_summary_state(sal_enums.State.DISABLED)

        # Send standby; new state is STANDBY.
        await self.remote.cmd_standby.start(timeout=timeout)  # type: ignore
        assert self.csc.summary_state == sal_enums.State.STANDBY
        await self.assert_next_summary_state(sal_enums.State.STANDBY)

        # Send exitControl; new state is OFFLINE.
        await self.remote.cmd_exitControl.start(timeout=timeout
                                                )  # type: ignore
        assert self.csc.summary_state == sal_enums.State.OFFLINE
        await self.assert_next_summary_state(sal_enums.State.OFFLINE)

    async def check_bad_commands(
        self,
        bad_commands: typing.Optional[typing.Sequence[str]] = None,
        good_commands: typing.Optional[typing.Sequence[str]] = None,
    ) -> None:
        """Check that bad commands fail.

        Parameters
        ----------
        bad_commands : `List`[`str`] or `None`, optional
            Names of bad commands to try, or None for all commands.
        good_commands : `List`[`str`] or `None`, optional
            Names of good commands to skip, or None to skip none.

        Notes
        -----
        If a command appears in both lists, it is considered a good command,
        so it is skipped.
        """
        if bad_commands is None:
            bad_commands = self.remote.salinfo.command_names
        if good_commands is None:
            good_commands = ()
        commands = self.remote.salinfo.command_names
        for command in commands:
            if command in good_commands:
                continue
            with self.subTest(command=command):  # type: ignore
                cmd_attr = getattr(self.remote, f"cmd_{command}")
                with testutils.assertRaisesAckError(
                        ack=sal_enums.SalRetCode.CMD_FAILED):
                    await cmd_attr.start(timeout=STD_TIMEOUT)