python-dali 0.13__py3-none-any.whl → 0.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dali/device/helpers.py CHANGED
@@ -113,7 +113,7 @@ class DeviceInstanceTypeMapper:
113
113
  for example:
114
114
  ```
115
115
  dev_inst_map = DeviceInstanceTypeMapper()
116
- await driver.run_sequence(dev_inst_map.autodiscover())
116
+ await driver.send(dev_inst_map.autodiscover())
117
117
  ```
118
118
  """
119
119
 
dali/device/sequences.py CHANGED
@@ -62,7 +62,7 @@ def SetEventSchemes(
62
62
 
63
63
  Example:
64
64
  ```
65
- await driver.run_sequence(
65
+ await driver.send(
66
66
  SetEventSchemes(
67
67
  device=address.DeviceShort(1),
68
68
  instance=address.InstanceNumber(2),
@@ -114,7 +114,7 @@ def SetEventFilters(
114
114
  from dali import address
115
115
  from dali.device.pushbutton import InstanceEventFilter as filter_pb
116
116
 
117
- await driver.run_sequence(
117
+ await driver.send(
118
118
  SetEventFilters(
119
119
  device=address.DeviceShort(1),
120
120
  instance=address.InstanceNumber(2),
@@ -204,7 +204,7 @@ def QueryEventFilters(
204
204
  from dali import address
205
205
  from dali.device import pushbutton
206
206
 
207
- await driver.run_sequence(
207
+ await driver.send(
208
208
  QueryEventFilters(
209
209
  device=address.DeviceShort(1),
210
210
  instance=address.InstanceNumber(2),
dali/driver/hid.py CHANGED
@@ -10,6 +10,7 @@ import struct
10
10
  import logging
11
11
  import random
12
12
  import glob
13
+ import warnings
13
14
  from dali.exceptions import UnsupportedFrameTypeError, CommunicationError
14
15
  from dali.sequences import sleep as seq_sleep
15
16
  from dali.sequences import progress as seq_progress
@@ -23,6 +24,11 @@ from dali.gear.general import EnableDeviceType
23
24
  def _hex(b):
24
25
  return ''.join("%02X" % x for x in b)
25
26
 
27
+
28
+ def _single_command_sequence(cmd):
29
+ """Wrap a single command as a trivial sequence that returns its response."""
30
+ return (yield cmd)
31
+
26
32
  class _callback:
27
33
  """Helper class for callback registration
28
34
  """
@@ -174,41 +180,71 @@ class hid:
174
180
  if reconnect:
175
181
  self._reconnect_task = asyncio.ensure_future(self._reconnect())
176
182
 
177
- async def send(self, command, in_transaction=False, exceptions=None):
178
- """Send a DALI command and receive a response
183
+ async def send(self, msg, *, priority=5, progress=None, exceptions=None):
184
+ """Send a command or run a sequence as a single transaction
185
+
186
+ A bare Command is wrapped in a trivial sequence, so a single command
187
+ and a multi-command sequence share the same execution path. The whole
188
+ transaction is sent while holding the transaction_lock, so it cannot be
189
+ interrupted by another sender. Returns the command's response for a
190
+ single command, otherwise the sequence's return value.
179
191
 
180
- Sends the command. Returns a response, or None if the command
181
- does not expect a response.
192
+ When a command has a non-zero device type, an EnableDeviceType frame is
193
+ transmitted immediately before it.
182
194
 
183
- If you have acquired the transaction_lock to perform a
184
- transaction, you must set the in_transaction keyword argument
185
- to True.
195
+ priority is accepted for a uniform driver API and validated against the
196
+ IEC 62386-101 Table 22 range 2..5 (a value outside it raises
197
+ ValueError), but HID dongles transmit at a fixed priority, so it has no
198
+ effect on the wire.
186
199
 
187
- This call can raise dali.exceptions.CommunicationError if
188
- there is a problem sending the command to the device. If you
189
- prefer to wait for the device to become available again, pass
190
- exceptions=False or set the exceptions_on_send attribute to False.
200
+ This call can raise dali.exceptions.CommunicationError if there is a
201
+ problem sending a command to the device. If you prefer to wait for the
202
+ device to become available again, pass exceptions=False or set the
203
+ exceptions_on_send attribute to False.
191
204
  """
205
+ if priority not in (2, 3, 4, 5):
206
+ raise ValueError(
207
+ f"priority must be in the range 2..5, not {priority!r}"
208
+ )
192
209
  if exceptions is None:
193
210
  exceptions = self.exceptions_on_send
194
211
 
195
- if not in_transaction:
196
- await self.transaction_lock.acquire()
212
+ seq = (
213
+ _single_command_sequence(msg)
214
+ if isinstance(msg, dali.command.Command)
215
+ else msg
216
+ )
217
+
218
+ await self.transaction_lock.acquire()
219
+ response = None
197
220
  try:
198
- command_sent = False
199
- while not command_sent:
221
+ while True:
200
222
  try:
201
- if command.devicetype != 0:
202
- await self._send_raw(EnableDeviceType(command.devicetype))
203
- response = await self._send_raw(command)
204
- command_sent = True
205
- except CommunicationError:
206
- if exceptions:
207
- raise
208
- return response
223
+ cmd = seq.send(response)
224
+ except StopIteration as r:
225
+ return r.value
226
+ response = None
227
+ if isinstance(cmd, seq_sleep):
228
+ await asyncio.sleep(cmd.delay)
229
+ elif isinstance(cmd, seq_progress):
230
+ if progress:
231
+ progress(cmd)
232
+ else:
233
+ command_sent = False
234
+ while not command_sent:
235
+ try:
236
+ if cmd.devicetype != 0:
237
+ await self._send_raw(
238
+ EnableDeviceType(cmd.devicetype)
239
+ )
240
+ response = await self._send_raw(cmd)
241
+ command_sent = True
242
+ except CommunicationError:
243
+ if exceptions:
244
+ raise
209
245
  finally:
210
- if not in_transaction:
211
- self.transaction_lock.release()
246
+ self.transaction_lock.release()
247
+ seq.close()
212
248
 
213
249
  async def power_supply(self, supply_on, in_transaction=False, exceptions=None):
214
250
  """
@@ -234,29 +270,14 @@ class hid:
234
270
  self.transaction_lock.release()
235
271
 
236
272
  async def run_sequence(self, seq, progress=None):
237
- """Run a command sequence as a transaction
273
+ """Deprecated alias for send(), which now accepts commands and sequences
238
274
  """
239
- await self.transaction_lock.acquire()
240
- response = None
241
- try:
242
- while True:
243
- try:
244
- cmd = seq.send(response)
245
- except StopIteration as r:
246
- return r.value
247
- response = None
248
- if isinstance(cmd, seq_sleep):
249
- await asyncio.sleep(cmd.delay)
250
- elif isinstance(cmd, seq_progress):
251
- if progress:
252
- progress(cmd)
253
- else:
254
- if cmd.devicetype != 0:
255
- await self._send_raw(EnableDeviceType(cmd.devicetype))
256
- response = await self._send_raw(cmd)
257
- finally:
258
- self.transaction_lock.release()
259
- seq.close()
275
+ warnings.warn(
276
+ "run_sequence() is deprecated; use send() instead",
277
+ DeprecationWarning,
278
+ stacklevel=2,
279
+ )
280
+ return await self.send(seq, progress=progress)
260
281
 
261
282
  def _initialise_device(self):
262
283
  """Send any device-specific initialisation commands
dali/driver/serial.py CHANGED
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
 
24
24
  import asyncio
25
25
  import logging
26
+ import warnings
26
27
  from enum import Enum
27
28
  from functools import reduce
28
29
  from operator import xor
@@ -57,6 +58,13 @@ def _drain_queue(queue: asyncio.Queue) -> list:
57
58
  return items
58
59
 
59
60
 
61
+ def _single_command_sequence(
62
+ cmd: command.Command,
63
+ ) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]:
64
+ """Wrap a single command as a trivial sequence that returns its response."""
65
+ return (yield cmd)
66
+
67
+
60
68
  class DistributorQueue(asyncio.Queue):
61
69
  def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize=0):
62
70
  """
@@ -215,22 +223,24 @@ class DriverSerialBase:
215
223
  """
216
224
  await self._connected.wait()
217
225
 
218
- async def send(
219
- self, msg: command.Command, in_transaction: bool = False
226
+ async def _send_frame(
227
+ self, cmd: command.Command, *, priority: int = 1
220
228
  ) -> Optional[command.Response]:
221
229
  """
222
- Send one DALI command over the bus using the driver. If the command
223
- expects a response this will be returned.
224
-
225
- :param msg: A Command object to send over the DALI bus
226
- :param in_transaction: Boolean flag to indicate if this `send()` call
227
- is part of a transaction, i.e. where the driver will block sending
228
- other messages until the transaction is complete. This is typically
229
- only needed internally, by the `run_sequence()` method.
230
+ Transmit a single DALI frame over the bus. If the command expects a
231
+ response it is returned.
232
+
233
+ This is the low-level, per-frame transmit used by `send()`. Callers
234
+ should use `send()` instead, which runs commands and sequences as
235
+ transactions and takes care of EnableDeviceType and priority.
236
+
237
+ :param cmd: A Command object to transmit
238
+ :param priority: IEC 62386-101 Table 22 transmit priority (1..5) for
239
+ this frame. Drivers that cannot set a transmit priority ignore it.
230
240
  :return: Either None if no response is expected, or a Response object
231
241
  """
232
242
  raise NotImplementedError(
233
- "'send()' needs to be implemented in a subclass"
243
+ "'_send_frame()' needs to be implemented in a subclass"
234
244
  )
235
245
 
236
246
  def new_dali_rx_queue(self) -> DistributorQueue:
@@ -259,29 +269,52 @@ class DriverSerialBase:
259
269
  "'new_dali_rx_queue()' needs to be implemented in a subclass"
260
270
  )
261
271
 
262
- async def run_sequence(
272
+ async def send(
263
273
  self,
264
- seq: Generator[
265
- command.Command, # Sequences yield commands to send
266
- command.Response, # Sequences get sent the response from the previous command
267
- Any, # The return type depends specifically on the sequence
268
- ],
274
+ msg: command.Command | Generator,
275
+ *,
276
+ priority: int = 5,
269
277
  progress: Optional[Callable[[str | sequences.progress], None]] = None,
270
278
  ) -> Any:
271
279
  """
272
- Run a command sequence as a transaction. Implements the same API as
273
- the 'hid' drivers.
274
-
275
- :param seq: A "generator" function to use as a sequence. These are
276
- available in various places in the python-dali library.
277
- :param progress: A function to call with progress updates, used by
278
- some sequences to provide status information. The function must
279
- accept a single argument. A suitable example is `progress=print` to
280
- use the built-in `print()` function.
281
- :return: Depends on the sequence being used
280
+ Send a command or run a sequence as a single transaction.
281
+
282
+ A bare Command is wrapped in a trivial sequence, so a single command
283
+ and a multi-command sequence share the same execution path. The whole
284
+ transaction is sent while holding the transaction lock, so it cannot be
285
+ interrupted by another sender.
286
+
287
+ The first forward frame of the transaction is sent at `priority`; all
288
+ subsequent frames are sent at priority 1, forming an IEC 62386-101 §9.3
289
+ transaction. When a command has a non-zero device type, an
290
+ EnableDeviceType frame is transmitted immediately before it (taking the
291
+ caller's priority, so the command itself follows at priority 1).
292
+
293
+ :param msg: A Command to send, or a sequence generator to run. Sequences
294
+ are available in various places in the python-dali library.
295
+ :param priority: IEC 62386-101 Table 22 priority for the first frame, in
296
+ the range 2..5 (see IEC 62386-103 §9.14 for how to choose it). A value
297
+ outside this range raises ValueError.
298
+ :param progress: A function to call with progress updates, used by some
299
+ sequences to provide status information. The function must accept a
300
+ single argument. A suitable example is `progress=print`.
301
+ :return: The command response for a single command, otherwise the
302
+ sequence's return value.
282
303
  """
304
+ if priority not in (2, 3, 4, 5):
305
+ raise ValueError(
306
+ f"priority must be in the range 2..5, not {priority!r}"
307
+ )
308
+
309
+ seq = (
310
+ _single_command_sequence(msg)
311
+ if isinstance(msg, command.Command)
312
+ else msg
313
+ )
314
+
283
315
  async with self.transaction_lock:
284
316
  response = None
317
+ next_priority = priority
285
318
  try:
286
319
  while True:
287
320
  try:
@@ -298,16 +331,38 @@ class DriverSerialBase:
298
331
  progress(cmd)
299
332
  else:
300
333
  if cmd.devicetype != 0:
301
- # The 'send()' calls here *do* refer to the DALI
302
- # transmit method
303
- await self.send(
334
+ await self._send_frame(
304
335
  gear.general.EnableDeviceType(cmd.devicetype),
305
- in_transaction=True,
336
+ priority=next_priority,
306
337
  )
307
- response = await self.send(cmd, in_transaction=True)
338
+ next_priority = 1
339
+ response = await self._send_frame(
340
+ cmd, priority=next_priority
341
+ )
342
+ next_priority = 1
308
343
  finally:
309
344
  seq.close()
310
345
 
346
+ async def run_sequence(
347
+ self,
348
+ seq: Generator[
349
+ command.Command, # Sequences yield commands to send
350
+ command.Response, # Sequences get sent the response from the previous command
351
+ Any, # The return type depends specifically on the sequence
352
+ ],
353
+ progress: Optional[Callable[[str | sequences.progress], None]] = None,
354
+ ) -> Any:
355
+ """
356
+ Deprecated alias for `send()`, which now accepts both commands and
357
+ sequences. Kept for backwards compatibility.
358
+ """
359
+ warnings.warn(
360
+ "run_sequence() is deprecated; use send() instead",
361
+ DeprecationWarning,
362
+ stacklevel=2,
363
+ )
364
+ return await self.send(seq, progress=progress)
365
+
311
366
 
312
367
  def drivers_map() -> dict[str, type[DriverSerialBase]]:
313
368
  """
@@ -548,13 +603,16 @@ class DriverLubaRs232(DriverSerialBase):
548
603
  def _insert_checksum(in_ints: list[int]) -> None:
549
604
  in_ints[-1] = reduce(xor, in_ints[1:-1])
550
605
 
551
- async def send_dali_command(self, tx: command.Command) -> None:
606
+ async def send_dali_command(
607
+ self, tx: command.Command, priority: int = 1
608
+ ) -> None:
552
609
  """
553
610
  Sends a variable length DALI command (16 or 24 bits), waiting
554
611
  until the LUBA device confirms it has sent the message before
555
612
  returning the frame ID.
556
613
 
557
614
  :param tx: A single DALI command to send
615
+ :param priority: IEC 62386-101 Table 22 transmit priority (1..5)
558
616
  """
559
617
  # Make sure the serial interface is not in the process of reading
560
618
  # data before we send
@@ -565,18 +623,7 @@ class DriverLubaRs232(DriverSerialBase):
565
623
  raise ValueError(
566
624
  f"Only works with 16 or 24 bit messages, not {8*len(dali_ints)}"
567
625
  )
568
- # Determine the message priority - standard commands and DAPC are
569
- # high priority, others are low
570
- if (
571
- isinstance(tx, gear.general._StandardCommand)
572
- and not tx.response
573
- and not tx.sendtwice
574
- ) or isinstance(tx, gear.general.DAPC):
575
- priority = 0b00000010 # Priority 2 (second-highest)
576
- else:
577
- priority = 0b00000101 # Priority 5 (lowest)
578
- luba_mode = 0
579
- luba_mode |= priority
626
+ luba_mode = priority # IEC 62386-101 Table 22 priority (1..5)
580
627
  luba_mode |= 0b10000000 if tx.sendtwice else 0 # "Send Twice" flag
581
628
  tx_ints = [
582
629
  0x59, # ASCII 'Y'
@@ -1327,7 +1374,7 @@ class DriverLubaRs232(DriverSerialBase):
1327
1374
  # to instance types
1328
1375
  if scan_dev_inst:
1329
1376
  _LOG.info("Scanning DALI bus for control devices")
1330
- await self.run_sequence(self.dev_inst_map.autodiscover())
1377
+ await self.send(self.dev_inst_map.autodiscover())
1331
1378
  _LOG.info(
1332
1379
  f"Found {len(self.dev_inst_map.mapping)} enabled control "
1333
1380
  "device instances"
@@ -1358,8 +1405,8 @@ class DriverLubaRs232(DriverSerialBase):
1358
1405
  raise IOError("DALI driver cannot query, not connected")
1359
1406
  return await self._protocol.send_read_status()
1360
1407
 
1361
- async def send(
1362
- self, msg: command.Command, in_transaction: bool = False
1408
+ async def _send_frame(
1409
+ self, cmd: command.Command, *, priority: int = 1
1363
1410
  ) -> Optional[command.Response]:
1364
1411
  # Only send if the driver is connected
1365
1412
  if not self.is_connected:
@@ -1368,47 +1415,39 @@ class DriverLubaRs232(DriverSerialBase):
1368
1415
 
1369
1416
  response = None
1370
1417
 
1371
- if not in_transaction:
1372
- await self.transaction_lock.acquire()
1373
- try:
1374
- # Make sure the received command buffer is empty, so that an
1375
- # unexpected response can't accidentally be used
1376
- self._protocol.reset_dali_response()
1377
- await self._protocol.send_dali_command(msg)
1378
- if msg.is_query:
1379
- # Default to the command's own response type so that a query
1380
- # which times out is reported as a missing value; a bare
1381
- # Response(None) passes check_bad_rsp and leaks None to callers.
1382
- response = msg.response(None)
1383
- while True:
1384
- try:
1385
- raw_rsp = await asyncio.wait_for(
1386
- self._protocol.wait_dali_raw_response(),
1387
- timeout=DriverLubaRs232.timeout_rx,
1388
- )
1389
- except asyncio.exceptions.TimeoutError:
1390
- _LOG.debug(
1391
- f"DALI response timeout, from message: {msg}"
1392
- )
1393
- break
1394
- if raw_rsp is _DALI_FRAMING_ERROR:
1395
- response = msg.response(frame.BackwardFrameError(255))
1396
- _LOG.debug(f"DALI framing error, from message: {msg}")
1397
- break
1398
- if isinstance(raw_rsp, int):
1399
- response = msg.response(frame.BackwardFrame(raw_rsp))
1400
- _LOG.debug(f"DALI response received: {raw_rsp}")
1401
- break
1402
- else:
1403
- _LOG.warning(
1404
- "DALI response expected to be 'int' but got type "
1405
- f"'{type(raw_rsp)}': {raw_rsp}"
1406
- )
1407
- raw_rsp = None
1408
- continue
1409
- finally:
1410
- if not in_transaction:
1411
- self.transaction_lock.release()
1418
+ # Make sure the received command buffer is empty, so that an
1419
+ # unexpected response can't accidentally be used
1420
+ self._protocol.reset_dali_response()
1421
+ await self._protocol.send_dali_command(cmd, priority=priority)
1422
+ if cmd.is_query:
1423
+ # Default to the command's own response type so that a query
1424
+ # which times out is reported as a missing value; a bare
1425
+ # Response(None) passes check_bad_rsp and leaks None to callers.
1426
+ response = cmd.response(None)
1427
+ while True:
1428
+ try:
1429
+ raw_rsp = await asyncio.wait_for(
1430
+ self._protocol.wait_dali_raw_response(),
1431
+ timeout=DriverLubaRs232.timeout_rx,
1432
+ )
1433
+ except asyncio.exceptions.TimeoutError:
1434
+ _LOG.debug(f"DALI response timeout, from message: {cmd}")
1435
+ break
1436
+ if raw_rsp is _DALI_FRAMING_ERROR:
1437
+ response = cmd.response(frame.BackwardFrameError(255))
1438
+ _LOG.debug(f"DALI framing error, from message: {cmd}")
1439
+ break
1440
+ if isinstance(raw_rsp, int):
1441
+ response = cmd.response(frame.BackwardFrame(raw_rsp))
1442
+ _LOG.debug(f"DALI response received: {raw_rsp}")
1443
+ break
1444
+ else:
1445
+ _LOG.warning(
1446
+ "DALI response expected to be 'int' but got type "
1447
+ f"'{type(raw_rsp)}': {raw_rsp}"
1448
+ )
1449
+ raw_rsp = None
1450
+ continue
1412
1451
 
1413
1452
  return response
1414
1453
 
@@ -1944,15 +1983,17 @@ class DriverSCIRS232(DriverSerialBase):
1944
1983
  # to instance types
1945
1984
  if scan_dev_inst:
1946
1985
  _LOG.info("Scanning DALI bus for control devices")
1947
- await self.run_sequence(self.dev_inst_map.autodiscover())
1986
+ await self.send(self.dev_inst_map.autodiscover())
1948
1987
  _LOG.info(
1949
1988
  f"Found {len(self.dev_inst_map.mapping)} enabled control "
1950
1989
  "device instances"
1951
1990
  )
1952
1991
 
1953
- async def send(
1954
- self, msg: command.Command, in_transaction: bool = False
1992
+ async def _send_frame(
1993
+ self, cmd: command.Command, *, priority: int = 1
1955
1994
  ) -> Optional[command.Response]:
1995
+ # The SCI RS232 protocol has no transmit priority field, so `priority`
1996
+ # is accepted for a uniform driver API but not used on the wire.
1956
1997
  # Only send if the driver is connected
1957
1998
  if not self.is_connected:
1958
1999
  _LOG.critical(f"DALI driver cannot send, not connected: {self}")
@@ -1960,44 +2001,36 @@ class DriverSCIRS232(DriverSerialBase):
1960
2001
 
1961
2002
  response = None
1962
2003
 
1963
- if not in_transaction:
1964
- await self.transaction_lock.acquire()
1965
- try:
1966
- # Make sure the received command buffer is empty, so that an
1967
- # unexpected response can't accidentally be used
1968
- self._protocol.reset_dali_response()
1969
- await self._protocol.send_dali_command(msg)
1970
- if msg.is_query:
1971
- # Default to the command's own response type so that a query
1972
- # which times out is reported as a missing value; a bare
1973
- # Response(None) passes check_bad_rsp and leaks None to callers.
1974
- response = msg.response(None)
1975
- while True:
1976
- try:
1977
- raw_rsp = await asyncio.wait_for(
1978
- self._protocol._queue_rx_raw_dali.get(),
1979
- #self._protocol.wait_dali_raw_response(),
1980
- timeout=DriverSCIRS232.timeout_rx,
1981
- )
1982
- except asyncio.exceptions.TimeoutError:
1983
- _LOG.debug(
1984
- f"DALI response timeout, from message: {msg}"
1985
- )
1986
- break
1987
- if isinstance(raw_rsp, int):
1988
- response = msg.response(frame.BackwardFrame(raw_rsp))
1989
- _LOG.debug(f"DALI response received: {raw_rsp}")
1990
- break
1991
- else:
1992
- _LOG.warning(
1993
- "DALI response expected to be 'int' but got type "
1994
- f"'{type(raw_rsp)}': {raw_rsp}"
1995
- )
1996
- raw_rsp = None
1997
- continue
1998
- finally:
1999
- if not in_transaction:
2000
- self.transaction_lock.release()
2004
+ # Make sure the received command buffer is empty, so that an
2005
+ # unexpected response can't accidentally be used
2006
+ self._protocol.reset_dali_response()
2007
+ await self._protocol.send_dali_command(cmd)
2008
+ if cmd.is_query:
2009
+ # Default to the command's own response type so that a query
2010
+ # which times out is reported as a missing value; a bare
2011
+ # Response(None) passes check_bad_rsp and leaks None to callers.
2012
+ response = cmd.response(None)
2013
+ while True:
2014
+ try:
2015
+ raw_rsp = await asyncio.wait_for(
2016
+ self._protocol._queue_rx_raw_dali.get(),
2017
+ #self._protocol.wait_dali_raw_response(),
2018
+ timeout=DriverSCIRS232.timeout_rx,
2019
+ )
2020
+ except asyncio.exceptions.TimeoutError:
2021
+ _LOG.debug(f"DALI response timeout, from message: {cmd}")
2022
+ break
2023
+ if isinstance(raw_rsp, int):
2024
+ response = cmd.response(frame.BackwardFrame(raw_rsp))
2025
+ _LOG.debug(f"DALI response received: {raw_rsp}")
2026
+ break
2027
+ else:
2028
+ _LOG.warning(
2029
+ "DALI response expected to be 'int' but got type "
2030
+ f"'{type(raw_rsp)}': {raw_rsp}"
2031
+ )
2032
+ raw_rsp = None
2033
+ continue
2001
2034
 
2002
2035
  return response
2003
2036
 
@@ -183,6 +183,10 @@ class DriverSerialDummy(DriverSerialBase):
183
183
  super().__init__(uri=uri, dev_inst_map=dev_inst_map)
184
184
  uri = self.uri
185
185
 
186
+ # Record of every frame sent, as (command, priority) tuples, so tests
187
+ # can assert what was transmitted and at which priority.
188
+ self.sent_frames: list[tuple[command.Command, int]] = []
189
+
186
190
  self._log_path = uri.path
187
191
  query = parse_qs(uri.query, strict_parsing=True)
188
192
  self._num_leds = 0
@@ -289,75 +293,62 @@ class DriverSerialDummy(DriverSerialBase):
289
293
  # to instance types
290
294
  if scan_dev_inst:
291
295
  _LOG.info("Scanning DALI bus for control devices")
292
- await self.run_sequence(self.dev_inst_map.autodiscover())
296
+ await self.send(self.dev_inst_map.autodiscover())
293
297
  _LOG.info(f"Found {len(self.dev_inst_map.mapping)} instances")
294
298
 
295
- async def send(
296
- self, msg: command.Command, in_transaction: bool = False
299
+ async def _send_frame(
300
+ self, cmd: command.Command, *, priority: int = 1
297
301
  ) -> Optional[command.Response]:
298
302
  """
299
- The 'send()' method for the DriverSerialDummy class doesn't actually
300
- communicate with anything external - it just tries to imitate hardware,
301
- at a very basic level. For now, all that is implemented is:
303
+ The '_send_frame()' method for the DriverSerialDummy class doesn't
304
+ actually communicate with anything external - it just tries to imitate
305
+ hardware, at a very basic level. For now, all that is implemented is:
302
306
  * Short addressing (i.e. referring to a dummy directly by its address)
303
307
  * Broadcast addressing
304
308
  * Brightness/Power Level (DAPC, RecallMaxLevel, RecallMinLevel, Off)
305
309
  * Device Type
306
310
 
307
- :param msg: The DALI message to send
308
- :param in_transaction: Flag whether or not this 'send()' call is part
309
- of a transaction (i.e. if this call is coming from the 'run_sequence()'
310
- method). If the flag is set then the lock will not be acquired before
311
- sending.
311
+ Every transmitted frame is appended to `self.sent_frames` as a
312
+ `(command, priority)` tuple, so tests can assert what was sent and at
313
+ which IEC 62386-101 Table 22 priority.
314
+
315
+ :param cmd: The DALI message to send
316
+ :param priority: The transmit priority (1..5) chosen by `send()`
312
317
  :return: If a command being sent expects a response, and the type is
313
- supported by DriverSerialDummy, then it will be returned by the 'send()'
314
- call. If more than one command generates a response then only the last
315
- response will be returned. Otherwise, None.
318
+ supported by DriverSerialDummy, then it will be returned. Otherwise,
319
+ None.
316
320
  """
317
321
  # Only send if the driver is connected
318
322
  if not self.is_connected:
319
323
  _LOG.critical(f"Cannot send, driver is not connected: {self}")
320
324
  raise IOError("Cannot send, driver is not connected")
321
325
 
322
- # If multiple responses are requested in a list of messages to send,
323
- # only the last response will be returned
324
- response = None
325
-
326
- # "Send" each message, one at a time, and figure out what the dummy
327
- # response should be
328
- if not in_transaction:
329
- await self.transaction_lock.acquire()
330
- try:
331
- with open(self._log_path, mode="at", encoding="utf-8") as log_file:
332
- # Write the message to the log file, emulating sending it over
333
- # a DALI bus
334
- log_file.write(f"{self._get_date()} {msg}\n")
335
- # Because writing to a log file is much faster than writing
336
- # over RS232, there is a short delay here to make the dummy act
337
- # a bit more like a real DALI device
338
- await asyncio.sleep(DriverSerialDummy.hardware_delay)
339
- # Get the response from the dummy bus
340
- response = self._dummy_bus.send(msg)
341
-
342
- # Special handling for a Lunatone Jalousie dummy device
343
- if hasattr(msg, "destination"):
344
- if hasattr(msg.destination, "address"):
345
- if isinstance(msg, gear.general.GoToScene):
346
- dst = msg.destination.address
347
- if dst in self._cover_addrs:
348
- _LOG.info(
349
- f"Scheduling dummy call to turn off A{dst}"
350
- )
351
- asyncio.get_running_loop().call_later(
352
- delay=10.0,
353
- callback=lambda: self._set_level_off(
354
- addr=dst
355
- ),
356
- )
357
-
358
- finally:
359
- if not in_transaction:
360
- self.transaction_lock.release()
326
+ self.sent_frames.append((cmd, priority))
327
+
328
+ with open(self._log_path, mode="at", encoding="utf-8") as log_file:
329
+ # Write the message to the log file, emulating sending it over
330
+ # a DALI bus
331
+ log_file.write(f"{self._get_date()} {cmd}\n")
332
+ # Because writing to a log file is much faster than writing
333
+ # over RS232, there is a short delay here to make the dummy act
334
+ # a bit more like a real DALI device
335
+ await asyncio.sleep(DriverSerialDummy.hardware_delay)
336
+ # Get the response from the dummy bus
337
+ response = self._dummy_bus.send(cmd)
338
+
339
+ # Special handling for a Lunatone Jalousie dummy device
340
+ if hasattr(cmd, "destination"):
341
+ if hasattr(cmd.destination, "address"):
342
+ if isinstance(cmd, gear.general.GoToScene):
343
+ dst = cmd.destination.address
344
+ if dst in self._cover_addrs:
345
+ _LOG.info(
346
+ f"Scheduling dummy call to turn off A{dst}"
347
+ )
348
+ asyncio.get_running_loop().call_later(
349
+ delay=10.0,
350
+ callback=lambda: self._set_level_off(addr=dst),
351
+ )
361
352
 
362
353
  return response
363
354
 
dali/tests/test_dummy.py CHANGED
@@ -26,6 +26,7 @@ import pytest
26
26
  from dali.driver.serial import DriverSerialBase, DriverLubaRs232, DriverSCIRS232, drivers_map
27
27
  from dali.tests.fakes_serial import DriverSerialDummy
28
28
  from dali import address, gear
29
+ from dali.gear.colour import QueryColourStatus
29
30
  from dali.sequences import QueryDeviceTypes
30
31
 
31
32
 
@@ -171,15 +172,106 @@ async def test_dummy_write_3(dummy_driver):
171
172
  assert "GoToScene(<address (control gear) 1>,11)" in dummy_driver.log.read()
172
173
 
173
174
 
175
+ @pytest.mark.asyncio
176
+ async def test_dummy_send_auto_prefixes_enable_device_type(dummy_driver):
177
+ """
178
+ A bare command with a non-zero device type is sent with an
179
+ EnableDeviceType frame immediately before it, so callers no longer need a
180
+ trivial wrapper sequence to do the prefixing.
181
+ """
182
+ await dummy_driver.driver.connect()
183
+ dummy_driver.driver.sent_frames.clear()
184
+ await dummy_driver.driver.send(QueryColourStatus(address.GearShort(0)))
185
+
186
+ sent = dummy_driver.driver.sent_frames
187
+ assert len(sent) == 2
188
+ assert isinstance(sent[0][0], gear.general.EnableDeviceType)
189
+ assert sent[0][0].param == 8
190
+ assert isinstance(sent[1][0], QueryColourStatus)
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_dummy_send_no_prefix_for_general_command(dummy_driver):
195
+ """
196
+ A device-type-0 command is sent on its own, without any EnableDeviceType.
197
+ """
198
+ await dummy_driver.driver.connect()
199
+ dummy_driver.driver.sent_frames.clear()
200
+ await dummy_driver.driver.send(gear.general.DAPC(address.GearShort(1), 254))
201
+
202
+ sent = dummy_driver.driver.sent_frames
203
+ assert len(sent) == 1
204
+ assert isinstance(sent[0][0], gear.general.DAPC)
205
+
206
+
207
+ @pytest.mark.parametrize("bad_priority", [0, 1, 6, -1, "5"])
208
+ @pytest.mark.asyncio
209
+ async def test_dummy_send_rejects_invalid_priority(dummy_driver, bad_priority):
210
+ """
211
+ Only IEC 62386-101 Table 22 priorities 2..5 are accepted.
212
+ """
213
+ await dummy_driver.driver.connect()
214
+ with pytest.raises(ValueError):
215
+ await dummy_driver.driver.send(
216
+ gear.general.DAPC(address.GearShort(1), 254),
217
+ priority=bad_priority,
218
+ )
219
+
220
+
221
+ @pytest.mark.parametrize("priority", [2, 3, 4, 5])
222
+ @pytest.mark.asyncio
223
+ async def test_dummy_send_valid_priority_accepted(dummy_driver, priority):
224
+ """
225
+ A general command is transmitted at exactly the requested priority.
226
+ """
227
+ await dummy_driver.driver.connect()
228
+ dummy_driver.driver.sent_frames.clear()
229
+ await dummy_driver.driver.send(
230
+ gear.general.DAPC(address.GearShort(1), 254), priority=priority
231
+ )
232
+
233
+ sent = dummy_driver.driver.sent_frames
234
+ assert len(sent) == 1
235
+ assert sent[0][1] == priority
236
+
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_dummy_send_transaction_priority(dummy_driver):
240
+ """
241
+ The first frame of a transaction carries the caller's priority and every
242
+ subsequent frame drops to priority 1 (IEC 62386-101 §9.3). The
243
+ auto-prefixed EnableDeviceType is the first frame, so it takes the caller's
244
+ priority and the command itself follows at priority 1.
245
+ """
246
+ await dummy_driver.driver.connect()
247
+ dummy_driver.driver.sent_frames.clear()
248
+ await dummy_driver.driver.send(
249
+ QueryColourStatus(address.GearShort(0)), priority=3
250
+ )
251
+
252
+ priorities = [priority for _, priority in dummy_driver.driver.sent_frames]
253
+ assert priorities == [3, 1]
254
+
255
+
256
+ @pytest.mark.asyncio
257
+ async def test_dummy_run_sequence_alias_warns(dummy_driver):
258
+ """
259
+ The deprecated run_sequence alias still works but warns.
260
+ """
261
+ await dummy_driver.driver.connect()
262
+ with pytest.warns(DeprecationWarning):
263
+ await dummy_driver.driver.run_sequence(
264
+ QueryDeviceTypes(address.GearShort(0))
265
+ )
266
+
267
+
174
268
  @pytest.mark.asyncio
175
269
  async def test_dummy_sequence_device_types(dummy_driver):
176
270
  await dummy_driver.driver.connect()
177
271
  # The default of dummy_driver has 12 addressed control gears
178
272
  for ad in range(11):
179
273
  short = address.GearShort(ad)
180
- dev_types = await dummy_driver.driver.run_sequence(
181
- QueryDeviceTypes(short)
182
- )
274
+ dev_types = await dummy_driver.driver.send(QueryDeviceTypes(short))
183
275
  # Colour Temperature LEDs, DT8, are created first by the dummy driver
184
276
  if ad in range(0, 5):
185
277
  assert len(dev_types) == 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dali
3
- Version: 0.13
3
+ Version: 0.14
4
4
  Summary: Interface to DALI lighting systems
5
5
  Home-page: https://github.com/sde1000/python-dali
6
6
  Author: Stephen Early
@@ -7,18 +7,18 @@ dali/sequences.py,sha256=GUJolgyKGC_sklnkFwqjVvbtGqURdhEOvuJ5X-aXIc4,8138
7
7
  dali/device/__init__.py,sha256=fppocY_qBnYI_tEgBGVFd8RVCcBXFwC8EMfB8p2sidY,451
8
8
  dali/device/general.py,sha256=mVYsHdCYn0nETgeenFquBqTL5idhcMSYk1TQPqev300,38702
9
9
  dali/device/general_sensor.py,sha256=sIIC4IOqMy3IAYMkf5TYrZVSXkIHeX6gzXzYorxDQmE,6007
10
- dali/device/helpers.py,sha256=JDn74F9ekhRciuz2KMhmX84kzXf8WRABkgukRA2shnQ,10796
10
+ dali/device/helpers.py,sha256=H9kgpEjTcIzb8h8iMb2TyDTc1RydJKCqTBhG3VlT630,10788
11
11
  dali/device/light.py,sha256=2L-NuydghLy31_g0CtaUlDNTJuo9RiKPL6XymTdpwbc,5556
12
12
  dali/device/occupancy.py,sha256=o0dbmVfEiVvU-EgZ6pw4RNb7f80y9wF4x548_qbZYLc,9972
13
13
  dali/device/pushbutton.py,sha256=S2WVxXsUS4qG056PVX9CgIGa7WKMd0Jc-xcLBszf1wE,8795
14
- dali/device/sequences.py,sha256=dGl3rAc_42n4fPduTS17WEgtwRwDd1AgItHX7hrlF0Y,15135
14
+ dali/device/sequences.py,sha256=pp0LUWyWL1Cay9xW3GePBpw2VS2OoERmLJRCt9q3l2g,15111
15
15
  dali/driver/__init__.py,sha256=BxBbq5TF8ShhFz6gy9cG8UeV3OStjBnW2z1esYfhsgQ,73
16
16
  dali/driver/atxled.py,sha256=5SvxiQkHIQZEbeFLW9C0FVKV90efimNcGDzBcVOYc88,5792
17
17
  dali/driver/base.py,sha256=Jd_0PrkVg84CdkVLmGBr5IyK5Sm7N68tqNDdPFRB7-0,3891
18
18
  dali/driver/daliserver.py,sha256=YbaTv8glbsPWtXy-m-zakxsPwZBu5FsuXxtVPfE1n78,3120
19
19
  dali/driver/hasseb.py,sha256=dQDrkxdpXlXlxmoShsoYOpvXpGzSLqZ8eqG-HMKIGOI,9376
20
- dali/driver/hid.py,sha256=YE0tKgdt8sRxl_8DzGCkSXeQRb9n6Jg59W5Ws0vOUq4,32645
21
- dali/driver/serial.py,sha256=-zQJsdyBfI0Ou7Mu3FYfRFDx3coSA7twExHW2qv1YRI,80525
20
+ dali/driver/hid.py,sha256=CyBkCZeyDFumzvfTUeTB3xIjALZkg6dugd3G4DYIeig,33591
21
+ dali/driver/serial.py,sha256=8bERuROWq_Sj5YH3--3Z-IOORXltmlTmqHf8hEGN_vk,81696
22
22
  dali/driver/trace_logging.py,sha256=qfkEdgVQsu-dXYKsmPXb6j7VRC6O1JJovuU8ca-KEMs,2366
23
23
  dali/driver/tridonic.py,sha256=H-j0u-7oP36uKxm-KdCO35AFuoyVzKu_jQs31jeDW6Y,10889
24
24
  dali/driver/unipi.py,sha256=arQ-o8zI-PtyvnyRa6f6kPKYswFtFvvdoKfCNRxTAiY,7399
@@ -41,11 +41,11 @@ dali/memory/maintenance.py,sha256=JQM2K0B0K_mkXQGL5acWI202QgPpevq-WPRye9Wx8-g,28
41
41
  dali/memory/oem.py,sha256=-sJJHlTKXgpr0aPPzAhwwRbxAKaC62jQ8LLJftS1GWk,6274
42
42
  dali/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
43
  dali/tests/fakes.py,sha256=YvXQ8aKJa9kN23G2jVLWHi3qKEemVYHppVJFlx7Z7MM,33739
44
- dali/tests/fakes_serial.py,sha256=HgB5cLvFcS_hLnHODMlaSsr6-qO957mSSF7vzSjSApo,13824
44
+ dali/tests/fakes_serial.py,sha256=1Dd-UPI43PmlRSafgPSS3MnxpRVXNAUJIO_SKPDMsg4,13355
45
45
  dali/tests/test_colour.py,sha256=2W2aFaaLG5g6I_1PT5umDzt0S8zYIZ5nF5-WjkC3TnQ,2582
46
46
  dali/tests/test_command.py,sha256=KPK6yQIQBZ0YN0QCPtZoPV181YSzjnC2JlJOORTEWm4,5566
47
47
  dali/tests/test_device_sequences.py,sha256=XxahtFRwL5IDA84TNOH6BMTWxbCkm77L0ka9w2PyjSk,17407
48
- dali/tests/test_dummy.py,sha256=jE_Y5ySdl-grSOJxNMvftZd4H9rNCK4yRuAKhLDPaEU,6198
48
+ dali/tests/test_dummy.py,sha256=gugklMbY_53d637vfR9ZFvXQ_szkMuNHz7-FirEAxeM,9412
49
49
  dali/tests/test_emergency.py,sha256=ok9QX1Wz0M70OFs0fltUusN1o6Ll58OKVgBpb5s4Z3g,2660
50
50
  dali/tests/test_events.py,sha256=4liFun8Efqs3NDRTDfKLvNs9l7NHmZiVur-_1LTEFTE,11822
51
51
  dali/tests/test_exceptions.py,sha256=qflkdpCMKOH3njsTlIvsRC1wIb1zhr2k-j6r0p3S_vQ,2090
@@ -55,10 +55,10 @@ dali/tests/test_general_sensor.py,sha256=jQqsjzUJa9rbX5pt1K9oavDiFXzMvqclx7qZ7FH
55
55
  dali/tests/test_memory.py,sha256=CGcoyhyd_pVC9C4yY0D7X83RIGs8RzYcDX32VuJvd3A,29526
56
56
  dali/tests/test_sequences.py,sha256=VaGy1WbUAXFRh-RU6v0OIOHKAhFwVh4KqU7O0N28zF8,4314
57
57
  dali/tests/test_serial_driver.py,sha256=oQ5qYCo93X7qxuhmySmqT5Ym97dkMZrzI-o2HmJsTpk,7531
58
- python_dali-0.13.dist-info/licenses/COPYING,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
59
- python_dali-0.13.dist-info/licenses/COPYING.LESSER,sha256=2n6rt7r999OuXp8iOqW9we7ORaxWncIbOwN1ILRGR2g,7651
60
- python_dali-0.13.dist-info/licenses/LICENSE,sha256=HVcO3iFdIg-1DaBsl4fgk3swO0aZDktmHBZPIhhn_5o,168
61
- python_dali-0.13.dist-info/METADATA,sha256=7tHU_spLisacici3kU0tvpdqpfn9fsNmKe7-hQmP-Uc,6200
62
- python_dali-0.13.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
63
- python_dali-0.13.dist-info/top_level.txt,sha256=VlSHNdAIGn5irB2PYMRKK0POsLocy-YusWmK3GwHViI,5
64
- python_dali-0.13.dist-info/RECORD,,
58
+ python_dali-0.14.dist-info/licenses/COPYING,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
59
+ python_dali-0.14.dist-info/licenses/COPYING.LESSER,sha256=2n6rt7r999OuXp8iOqW9we7ORaxWncIbOwN1ILRGR2g,7651
60
+ python_dali-0.14.dist-info/licenses/LICENSE,sha256=HVcO3iFdIg-1DaBsl4fgk3swO0aZDktmHBZPIhhn_5o,168
61
+ python_dali-0.14.dist-info/METADATA,sha256=x7ztPGVIdmPi80odDqzJ_Xtr-fvZiHJNJ1ajVC6N42I,6200
62
+ python_dali-0.14.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
63
+ python_dali-0.14.dist-info/top_level.txt,sha256=VlSHNdAIGn5irB2PYMRKK0POsLocy-YusWmK3GwHViI,5
64
+ python_dali-0.14.dist-info/RECORD,,