diff --git a/dali/device/helpers.py b/dali/device/helpers.py index 4c250a7b..e5b3faac 100644 --- a/dali/device/helpers.py +++ b/dali/device/helpers.py @@ -105,7 +105,7 @@ def autodiscover( for example: ``` dev_inst_map = DeviceInstanceTypeMapper() - await driver.run_sequence(dev_inst_map.autodiscover()) + await driver.send(dev_inst_map.autodiscover()) ``` """ diff --git a/dali/device/sequences.py b/dali/device/sequences.py index acf0c0ca..ed4bd7db 100644 --- a/dali/device/sequences.py +++ b/dali/device/sequences.py @@ -47,7 +47,7 @@ def SetEventSchemes( Example: ``` - await driver.run_sequence( + await driver.send( SetEventSchemes( device=address.DeviceShort(1), instance=address.InstanceNumber(2), @@ -99,7 +99,7 @@ def SetEventFilters( from dali import address from dali.device.pushbutton import InstanceEventFilter as filter_pb - await driver.run_sequence( + await driver.send( SetEventFilters( device=address.DeviceShort(1), instance=address.InstanceNumber(2), @@ -189,7 +189,7 @@ def QueryEventFilters( from dali import address from dali.device import pushbutton - await driver.run_sequence( + await driver.send( QueryEventFilters( device=address.DeviceShort(1), instance=address.InstanceNumber(2), diff --git a/dali/driver/hid.py b/dali/driver/hid.py index f8e48768..83a07a19 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -10,6 +10,7 @@ import logging import random import glob +import warnings from dali.exceptions import UnsupportedFrameTypeError, CommunicationError from dali.sequences import sleep as seq_sleep from dali.sequences import progress as seq_progress @@ -23,6 +24,11 @@ def _hex(b): return ''.join("%02X" % x for x in b) + +def _single_command_sequence(cmd): + """Wrap a single command as a trivial sequence that returns its response.""" + return (yield cmd) + class _callback: """Helper class for callback registration """ @@ -174,41 +180,71 @@ def disconnect(self, reconnect=False): if reconnect: self._reconnect_task = asyncio.ensure_future(self._reconnect()) - async def send(self, command, in_transaction=False, exceptions=None): - """Send a DALI command and receive a response + async def send(self, msg, *, priority=5, progress=None, exceptions=None): + """Send a command or run a sequence as a single transaction + + A bare Command is wrapped in a trivial sequence, so a single command + and a multi-command sequence share the same execution path. The whole + transaction is sent while holding the transaction_lock, so it cannot be + interrupted by another sender. Returns the command's response for a + single command, otherwise the sequence's return value. - Sends the command. Returns a response, or None if the command - does not expect a response. + When a command has a non-zero device type, an EnableDeviceType frame is + transmitted immediately before it. - If you have acquired the transaction_lock to perform a - transaction, you must set the in_transaction keyword argument - to True. + priority is accepted for a uniform driver API and validated against the + IEC 62386-101 Table 22 range 2..5 (a value outside it raises + ValueError), but HID dongles transmit at a fixed priority, so it has no + effect on the wire. - This call can raise dali.exceptions.CommunicationError if - there is a problem sending the command to the device. If you - prefer to wait for the device to become available again, pass - exceptions=False or set the exceptions_on_send attribute to False. + This call can raise dali.exceptions.CommunicationError if there is a + problem sending a command to the device. If you prefer to wait for the + device to become available again, pass exceptions=False or set the + exceptions_on_send attribute to False. """ + if priority not in (2, 3, 4, 5): + raise ValueError( + f"priority must be in the range 2..5, not {priority!r}" + ) if exceptions is None: exceptions = self.exceptions_on_send - if not in_transaction: - await self.transaction_lock.acquire() + seq = ( + _single_command_sequence(msg) + if isinstance(msg, dali.command.Command) + else msg + ) + + await self.transaction_lock.acquire() + response = None try: - command_sent = False - while not command_sent: + while True: try: - if command.devicetype != 0: - await self._send_raw(EnableDeviceType(command.devicetype)) - response = await self._send_raw(command) - command_sent = True - except CommunicationError: - if exceptions: - raise - return response + cmd = seq.send(response) + except StopIteration as r: + return r.value + response = None + if isinstance(cmd, seq_sleep): + await asyncio.sleep(cmd.delay) + elif isinstance(cmd, seq_progress): + if progress: + progress(cmd) + else: + command_sent = False + while not command_sent: + try: + if cmd.devicetype != 0: + await self._send_raw( + EnableDeviceType(cmd.devicetype) + ) + response = await self._send_raw(cmd) + command_sent = True + except CommunicationError: + if exceptions: + raise finally: - if not in_transaction: - self.transaction_lock.release() + self.transaction_lock.release() + seq.close() async def power_supply(self, supply_on, in_transaction=False, exceptions=None): """ @@ -234,29 +270,14 @@ async def power_supply(self, supply_on, in_transaction=False, exceptions=None): self.transaction_lock.release() async def run_sequence(self, seq, progress=None): - """Run a command sequence as a transaction + """Deprecated alias for send(), which now accepts commands and sequences """ - await self.transaction_lock.acquire() - response = None - try: - while True: - try: - cmd = seq.send(response) - except StopIteration as r: - return r.value - response = None - if isinstance(cmd, seq_sleep): - await asyncio.sleep(cmd.delay) - elif isinstance(cmd, seq_progress): - if progress: - progress(cmd) - else: - if cmd.devicetype != 0: - await self._send_raw(EnableDeviceType(cmd.devicetype)) - response = await self._send_raw(cmd) - finally: - self.transaction_lock.release() - seq.close() + warnings.warn( + "run_sequence() is deprecated; use send() instead", + DeprecationWarning, + stacklevel=2, + ) + return await self.send(seq, progress=progress) def _initialise_device(self): """Send any device-specific initialisation commands diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 24ba70b2..2338016f 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -23,6 +23,7 @@ import asyncio import logging +import warnings from enum import Enum from functools import reduce from operator import xor @@ -39,6 +40,13 @@ _LOG = logging.getLogger("dali.driver") +def _single_command_sequence( + cmd: command.Command, +) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: + """Wrap a single command as a trivial sequence that returns its response.""" + return (yield cmd) + + class DistributorQueue(asyncio.Queue): def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize=0): """ @@ -185,22 +193,24 @@ async def wait_connected(self) -> None: """ await self._connected.wait() - async def send( - self, msg: command.Command, in_transaction: bool = False + async def _send_frame( + self, cmd: command.Command, *, priority: int = 1 ) -> Optional[command.Response]: """ - Send one DALI command over the bus using the driver. If the command - expects a response this will be returned. - - :param msg: A Command object to send over the DALI bus - :param in_transaction: Boolean flag to indicate if this `send()` call - is part of a transaction, i.e. where the driver will block sending - other messages until the transaction is complete. This is typically - only needed internally, by the `run_sequence()` method. + Transmit a single DALI frame over the bus. If the command expects a + response it is returned. + + This is the low-level, per-frame transmit used by `send()`. Callers + should use `send()` instead, which runs commands and sequences as + transactions and takes care of EnableDeviceType and priority. + + :param cmd: A Command object to transmit + :param priority: IEC 62386-101 Table 22 transmit priority (1..5) for + this frame. Drivers that cannot set a transmit priority ignore it. :return: Either None if no response is expected, or a Response object """ raise NotImplementedError( - "'send()' needs to be implemented in a subclass" + "'_send_frame()' needs to be implemented in a subclass" ) def new_dali_rx_queue(self) -> DistributorQueue: @@ -229,29 +239,52 @@ def new_dali_rx_queue(self) -> DistributorQueue: "'new_dali_rx_queue()' needs to be implemented in a subclass" ) - async def run_sequence( + async def send( self, - seq: Generator[ - command.Command, # Sequences yield commands to send - command.Response, # Sequences get sent the response from the previous command - Any, # The return type depends specifically on the sequence - ], + msg: command.Command | Generator, + *, + priority: int = 5, progress: Optional[Callable[[str | sequences.progress], None]] = None, ) -> Any: """ - Run a command sequence as a transaction. Implements the same API as - the 'hid' drivers. - - :param seq: A "generator" function to use as a sequence. These are - available in various places in the python-dali library. - :param progress: A function to call with progress updates, used by - some sequences to provide status information. The function must - accept a single argument. A suitable example is `progress=print` to - use the built-in `print()` function. - :return: Depends on the sequence being used + Send a command or run a sequence as a single transaction. + + A bare Command is wrapped in a trivial sequence, so a single command + and a multi-command sequence share the same execution path. The whole + transaction is sent while holding the transaction lock, so it cannot be + interrupted by another sender. + + The first forward frame of the transaction is sent at `priority`; all + subsequent frames are sent at priority 1, forming an IEC 62386-101 §9.3 + transaction. When a command has a non-zero device type, an + EnableDeviceType frame is transmitted immediately before it (taking the + caller's priority, so the command itself follows at priority 1). + + :param msg: A Command to send, or a sequence generator to run. Sequences + are available in various places in the python-dali library. + :param priority: IEC 62386-101 Table 22 priority for the first frame, in + the range 2..5 (see IEC 62386-103 §9.14 for how to choose it). A value + outside this range raises ValueError. + :param progress: A function to call with progress updates, used by some + sequences to provide status information. The function must accept a + single argument. A suitable example is `progress=print`. + :return: The command response for a single command, otherwise the + sequence's return value. """ + if priority not in (2, 3, 4, 5): + raise ValueError( + f"priority must be in the range 2..5, not {priority!r}" + ) + + seq = ( + _single_command_sequence(msg) + if isinstance(msg, command.Command) + else msg + ) + async with self.transaction_lock: response = None + next_priority = priority try: while True: try: @@ -268,16 +301,38 @@ async def run_sequence( progress(cmd) else: if cmd.devicetype != 0: - # The 'send()' calls here *do* refer to the DALI - # transmit method - await self.send( + await self._send_frame( gear.general.EnableDeviceType(cmd.devicetype), - in_transaction=True, + priority=next_priority, ) - response = await self.send(cmd, in_transaction=True) + next_priority = 1 + response = await self._send_frame( + cmd, priority=next_priority + ) + next_priority = 1 finally: seq.close() + async def run_sequence( + self, + seq: Generator[ + command.Command, # Sequences yield commands to send + command.Response, # Sequences get sent the response from the previous command + Any, # The return type depends specifically on the sequence + ], + progress: Optional[Callable[[str | sequences.progress], None]] = None, + ) -> Any: + """ + Deprecated alias for `send()`, which now accepts both commands and + sequences. Kept for backwards compatibility. + """ + warnings.warn( + "run_sequence() is deprecated; use send() instead", + DeprecationWarning, + stacklevel=2, + ) + return await self.send(seq, progress=progress) + def drivers_map() -> dict[str, type[DriverSerialBase]]: """ @@ -462,13 +517,16 @@ def reset_dali_response(self) -> None: def _insert_checksum(in_ints: list[int]) -> None: in_ints[-1] = reduce(xor, in_ints[1:-1]) - async def send_dali_command(self, tx: command.Command) -> None: + async def send_dali_command( + self, tx: command.Command, priority: int = 1 + ) -> None: """ Sends a variable length DALI command (16 or 24 bits), waiting until the LUBA device confirms it has sent the message before returning the frame ID. :param tx: A single DALI command to send + :param priority: IEC 62386-101 Table 22 transmit priority (1..5) """ # Make sure the serial interface is not in the process of reading # data before we send @@ -479,18 +537,7 @@ async def send_dali_command(self, tx: command.Command) -> None: raise ValueError( f"Only works with 16 or 24 bit messages, not {8*len(dali_ints)}" ) - # Determine the message priority - standard commands and DAPC are - # high priority, others are low - if ( - isinstance(tx, gear.general._StandardCommand) - and not tx.response - and not tx.sendtwice - ) or isinstance(tx, gear.general.DAPC): - priority = 0b00000010 # Priority 2 (second-highest) - else: - priority = 0b00000101 # Priority 5 (lowest) - luba_mode = 0 - luba_mode |= priority + luba_mode = priority # IEC 62386-101 Table 22 priority (1..5) luba_mode |= 0b10000000 if tx.sendtwice else 0 # "Send Twice" flag tx_ints = [ 0x59, # ASCII 'Y' @@ -1055,14 +1102,14 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: # to instance types if scan_dev_inst: _LOG.info("Scanning DALI bus for control devices") - await self.run_sequence(self.dev_inst_map.autodiscover()) + await self.send(self.dev_inst_map.autodiscover()) _LOG.info( f"Found {len(self.dev_inst_map.mapping)} enabled control " "device instances" ) - async def send( - self, msg: command.Command, in_transaction: bool = False + async def _send_frame( + self, cmd: command.Command, *, priority: int = 1 ) -> Optional[command.Response]: # Only send if the driver is connected if not self.is_connected: @@ -1071,40 +1118,32 @@ async def send( response = None - if not in_transaction: - await self.transaction_lock.acquire() - try: - # Make sure the received command buffer is empty, so that an - # unexpected response can't accidentally be used - self._protocol.reset_dali_response() - await self._protocol.send_dali_command(msg) - if msg.is_query: - response = command.Response(None) - while True: - try: - raw_rsp = await asyncio.wait_for( - self._protocol.wait_dali_raw_response(), - timeout=DriverLubaRs232.timeout_rx, - ) - except asyncio.exceptions.TimeoutError: - _LOG.debug( - f"DALI response timeout, from message: {msg}" - ) - break - if isinstance(raw_rsp, int): - response = msg.response(frame.BackwardFrame(raw_rsp)) - _LOG.debug(f"DALI response received: {raw_rsp}") - break - else: - _LOG.warning( - "DALI response expected to be 'int' but got type " - f"'{type(raw_rsp)}': {raw_rsp}" - ) - raw_rsp = None - continue - finally: - if not in_transaction: - self.transaction_lock.release() + # Make sure the received command buffer is empty, so that an + # unexpected response can't accidentally be used + self._protocol.reset_dali_response() + await self._protocol.send_dali_command(cmd, priority=priority) + if cmd.is_query: + response = command.Response(None) + while True: + try: + raw_rsp = await asyncio.wait_for( + self._protocol.wait_dali_raw_response(), + timeout=DriverLubaRs232.timeout_rx, + ) + except asyncio.exceptions.TimeoutError: + _LOG.debug(f"DALI response timeout, from message: {cmd}") + break + if isinstance(raw_rsp, int): + response = cmd.response(frame.BackwardFrame(raw_rsp)) + _LOG.debug(f"DALI response received: {raw_rsp}") + break + else: + _LOG.warning( + "DALI response expected to be 'int' but got type " + f"'{type(raw_rsp)}': {raw_rsp}" + ) + raw_rsp = None + continue return response @@ -1646,15 +1685,17 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: # to instance types if scan_dev_inst: _LOG.info("Scanning DALI bus for control devices") - await self.run_sequence(self.dev_inst_map.autodiscover()) + await self.send(self.dev_inst_map.autodiscover()) _LOG.info( f"Found {len(self.dev_inst_map.mapping)} enabled control " "device instances" ) - async def send( - self, msg: command.Command, in_transaction: bool = False + async def _send_frame( + self, cmd: command.Command, *, priority: int = 1 ) -> Optional[command.Response]: + # The SCI RS232 protocol has no transmit priority field, so `priority` + # is accepted for a uniform driver API but not used on the wire. # Only send if the driver is connected if not self.is_connected: _LOG.critical(f"DALI driver cannot send, not connected: {self}") @@ -1662,41 +1703,33 @@ async def send( response = None - if not in_transaction: - await self.transaction_lock.acquire() - try: - # Make sure the received command buffer is empty, so that an - # unexpected response can't accidentally be used - self._protocol.reset_dali_response() - await self._protocol.send_dali_command(msg) - if msg.is_query: - response = command.Response(None) - while True: - try: - raw_rsp = await asyncio.wait_for( - self._protocol._queue_rx_raw_dali.get(), - #self._protocol.wait_dali_raw_response(), - timeout=DriverSCIRS232.timeout_rx, - ) - except asyncio.exceptions.TimeoutError: - _LOG.debug( - f"DALI response timeout, from message: {msg}" - ) - break - if isinstance(raw_rsp, int): - response = msg.response(frame.BackwardFrame(raw_rsp)) - _LOG.debug(f"DALI response received: {raw_rsp}") - break - else: - _LOG.warning( - "DALI response expected to be 'int' but got type " - f"'{type(raw_rsp)}': {raw_rsp}" - ) - raw_rsp = None - continue - finally: - if not in_transaction: - self.transaction_lock.release() + # Make sure the received command buffer is empty, so that an + # unexpected response can't accidentally be used + self._protocol.reset_dali_response() + await self._protocol.send_dali_command(cmd) + if cmd.is_query: + response = command.Response(None) + while True: + try: + raw_rsp = await asyncio.wait_for( + self._protocol._queue_rx_raw_dali.get(), + #self._protocol.wait_dali_raw_response(), + timeout=DriverSCIRS232.timeout_rx, + ) + except asyncio.exceptions.TimeoutError: + _LOG.debug(f"DALI response timeout, from message: {cmd}") + break + if isinstance(raw_rsp, int): + response = cmd.response(frame.BackwardFrame(raw_rsp)) + _LOG.debug(f"DALI response received: {raw_rsp}") + break + else: + _LOG.warning( + "DALI response expected to be 'int' but got type " + f"'{type(raw_rsp)}': {raw_rsp}" + ) + raw_rsp = None + continue return response diff --git a/dali/tests/fakes_serial.py b/dali/tests/fakes_serial.py index aee52cfc..4652924c 100644 --- a/dali/tests/fakes_serial.py +++ b/dali/tests/fakes_serial.py @@ -183,6 +183,10 @@ def __init__( super().__init__(uri=uri, dev_inst_map=dev_inst_map) uri = self.uri + # Record of every frame sent, as (command, priority) tuples, so tests + # can assert what was transmitted and at which priority. + self.sent_frames: list[tuple[command.Command, int]] = [] + self._log_path = uri.path query = parse_qs(uri.query, strict_parsing=True) self._num_leds = 0 @@ -289,75 +293,62 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: # to instance types if scan_dev_inst: _LOG.info("Scanning DALI bus for control devices") - await self.run_sequence(self.dev_inst_map.autodiscover()) + await self.send(self.dev_inst_map.autodiscover()) _LOG.info(f"Found {len(self.dev_inst_map.mapping)} instances") - async def send( - self, msg: command.Command, in_transaction: bool = False + async def _send_frame( + self, cmd: command.Command, *, priority: int = 1 ) -> Optional[command.Response]: """ - The 'send()' method for the DriverSerialDummy class doesn't actually - communicate with anything external - it just tries to imitate hardware, - at a very basic level. For now, all that is implemented is: + The '_send_frame()' method for the DriverSerialDummy class doesn't + actually communicate with anything external - it just tries to imitate + hardware, at a very basic level. For now, all that is implemented is: * Short addressing (i.e. referring to a dummy directly by its address) * Broadcast addressing * Brightness/Power Level (DAPC, RecallMaxLevel, RecallMinLevel, Off) * Device Type - :param msg: The DALI message to send - :param in_transaction: Flag whether or not this 'send()' call is part - of a transaction (i.e. if this call is coming from the 'run_sequence()' - method). If the flag is set then the lock will not be acquired before - sending. + Every transmitted frame is appended to `self.sent_frames` as a + `(command, priority)` tuple, so tests can assert what was sent and at + which IEC 62386-101 Table 22 priority. + + :param cmd: The DALI message to send + :param priority: The transmit priority (1..5) chosen by `send()` :return: If a command being sent expects a response, and the type is - supported by DriverSerialDummy, then it will be returned by the 'send()' - call. If more than one command generates a response then only the last - response will be returned. Otherwise, None. + supported by DriverSerialDummy, then it will be returned. Otherwise, + None. """ # Only send if the driver is connected if not self.is_connected: _LOG.critical(f"Cannot send, driver is not connected: {self}") raise IOError("Cannot send, driver is not connected") - # If multiple responses are requested in a list of messages to send, - # only the last response will be returned - response = None - - # "Send" each message, one at a time, and figure out what the dummy - # response should be - if not in_transaction: - await self.transaction_lock.acquire() - try: - with open(self._log_path, mode="at", encoding="utf-8") as log_file: - # Write the message to the log file, emulating sending it over - # a DALI bus - log_file.write(f"{self._get_date()} {msg}\n") - # Because writing to a log file is much faster than writing - # over RS232, there is a short delay here to make the dummy act - # a bit more like a real DALI device - await asyncio.sleep(DriverSerialDummy.hardware_delay) - # Get the response from the dummy bus - response = self._dummy_bus.send(msg) - - # Special handling for a Lunatone Jalousie dummy device - if hasattr(msg, "destination"): - if hasattr(msg.destination, "address"): - if isinstance(msg, gear.general.GoToScene): - dst = msg.destination.address - if dst in self._cover_addrs: - _LOG.info( - f"Scheduling dummy call to turn off A{dst}" - ) - asyncio.get_running_loop().call_later( - delay=10.0, - callback=lambda: self._set_level_off( - addr=dst - ), - ) - - finally: - if not in_transaction: - self.transaction_lock.release() + self.sent_frames.append((cmd, priority)) + + with open(self._log_path, mode="at", encoding="utf-8") as log_file: + # Write the message to the log file, emulating sending it over + # a DALI bus + log_file.write(f"{self._get_date()} {cmd}\n") + # Because writing to a log file is much faster than writing + # over RS232, there is a short delay here to make the dummy act + # a bit more like a real DALI device + await asyncio.sleep(DriverSerialDummy.hardware_delay) + # Get the response from the dummy bus + response = self._dummy_bus.send(cmd) + + # Special handling for a Lunatone Jalousie dummy device + if hasattr(cmd, "destination"): + if hasattr(cmd.destination, "address"): + if isinstance(cmd, gear.general.GoToScene): + dst = cmd.destination.address + if dst in self._cover_addrs: + _LOG.info( + f"Scheduling dummy call to turn off A{dst}" + ) + asyncio.get_running_loop().call_later( + delay=10.0, + callback=lambda: self._set_level_off(addr=dst), + ) return response diff --git a/dali/tests/test_dummy.py b/dali/tests/test_dummy.py index bd568b7a..98b4f42c 100644 --- a/dali/tests/test_dummy.py +++ b/dali/tests/test_dummy.py @@ -26,6 +26,7 @@ from dali.driver.serial import DriverSerialBase, DriverLubaRs232, DriverSCIRS232, drivers_map from dali.tests.fakes_serial import DriverSerialDummy from dali import address, gear +from dali.gear.colour import QueryColourStatus from dali.sequences import QueryDeviceTypes @@ -171,15 +172,106 @@ async def test_dummy_write_3(dummy_driver): assert "GoToScene(
,11)" in dummy_driver.log.read() +@pytest.mark.asyncio +async def test_dummy_send_auto_prefixes_enable_device_type(dummy_driver): + """ + A bare command with a non-zero device type is sent with an + EnableDeviceType frame immediately before it, so callers no longer need a + trivial wrapper sequence to do the prefixing. + """ + await dummy_driver.driver.connect() + dummy_driver.driver.sent_frames.clear() + await dummy_driver.driver.send(QueryColourStatus(address.GearShort(0))) + + sent = dummy_driver.driver.sent_frames + assert len(sent) == 2 + assert isinstance(sent[0][0], gear.general.EnableDeviceType) + assert sent[0][0].param == 8 + assert isinstance(sent[1][0], QueryColourStatus) + + +@pytest.mark.asyncio +async def test_dummy_send_no_prefix_for_general_command(dummy_driver): + """ + A device-type-0 command is sent on its own, without any EnableDeviceType. + """ + await dummy_driver.driver.connect() + dummy_driver.driver.sent_frames.clear() + await dummy_driver.driver.send(gear.general.DAPC(address.GearShort(1), 254)) + + sent = dummy_driver.driver.sent_frames + assert len(sent) == 1 + assert isinstance(sent[0][0], gear.general.DAPC) + + +@pytest.mark.parametrize("bad_priority", [0, 1, 6, -1, "5"]) +@pytest.mark.asyncio +async def test_dummy_send_rejects_invalid_priority(dummy_driver, bad_priority): + """ + Only IEC 62386-101 Table 22 priorities 2..5 are accepted. + """ + await dummy_driver.driver.connect() + with pytest.raises(ValueError): + await dummy_driver.driver.send( + gear.general.DAPC(address.GearShort(1), 254), + priority=bad_priority, + ) + + +@pytest.mark.parametrize("priority", [2, 3, 4, 5]) +@pytest.mark.asyncio +async def test_dummy_send_valid_priority_accepted(dummy_driver, priority): + """ + A general command is transmitted at exactly the requested priority. + """ + await dummy_driver.driver.connect() + dummy_driver.driver.sent_frames.clear() + await dummy_driver.driver.send( + gear.general.DAPC(address.GearShort(1), 254), priority=priority + ) + + sent = dummy_driver.driver.sent_frames + assert len(sent) == 1 + assert sent[0][1] == priority + + +@pytest.mark.asyncio +async def test_dummy_send_transaction_priority(dummy_driver): + """ + The first frame of a transaction carries the caller's priority and every + subsequent frame drops to priority 1 (IEC 62386-101 §9.3). The + auto-prefixed EnableDeviceType is the first frame, so it takes the caller's + priority and the command itself follows at priority 1. + """ + await dummy_driver.driver.connect() + dummy_driver.driver.sent_frames.clear() + await dummy_driver.driver.send( + QueryColourStatus(address.GearShort(0)), priority=3 + ) + + priorities = [priority for _, priority in dummy_driver.driver.sent_frames] + assert priorities == [3, 1] + + +@pytest.mark.asyncio +async def test_dummy_run_sequence_alias_warns(dummy_driver): + """ + The deprecated run_sequence alias still works but warns. + """ + await dummy_driver.driver.connect() + with pytest.warns(DeprecationWarning): + await dummy_driver.driver.run_sequence( + QueryDeviceTypes(address.GearShort(0)) + ) + + @pytest.mark.asyncio async def test_dummy_sequence_device_types(dummy_driver): await dummy_driver.driver.connect() # The default of dummy_driver has 12 addressed control gears for ad in range(11): short = address.GearShort(ad) - dev_types = await dummy_driver.driver.run_sequence( - QueryDeviceTypes(short) - ) + dev_types = await dummy_driver.driver.send(QueryDeviceTypes(short)) # Colour Temperature LEDs, DT8, are created first by the dummy driver if ad in range(0, 5): assert len(dev_types) == 1