python-dali 0.11__tar.gz → 0.12__tar.gz

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.
Files changed (66) hide show
  1. {python-dali-0.11/python_dali.egg-info → python_dali-0.12}/PKG-INFO +20 -14
  2. {python-dali-0.11 → python_dali-0.12}/README.rst +6 -2
  3. {python-dali-0.11 → python_dali-0.12}/dali/driver/base.py +0 -114
  4. {python-dali-0.11 → python_dali-0.12}/dali/driver/hid.py +51 -27
  5. {python-dali-0.11 → python_dali-0.12}/dali/driver/serial.py +40 -4
  6. {python-dali-0.11 → python_dali-0.12}/dali/driver/tridonic.py +2 -2
  7. {python-dali-0.11 → python_dali-0.12}/dali/driver/unipi.py +18 -8
  8. python_dali-0.12/dali/driver/usb.py +114 -0
  9. {python-dali-0.11 → python_dali-0.12}/dali/gear/__init__.py +1 -0
  10. python_dali-0.12/dali/gear/converter.py +173 -0
  11. {python-dali-0.11 → python_dali-0.12}/dali/gear/sequences.py +1 -0
  12. {python-dali-0.11 → python_dali-0.12/python_dali.egg-info}/PKG-INFO +20 -14
  13. {python-dali-0.11 → python_dali-0.12}/python_dali.egg-info/SOURCES.txt +2 -1
  14. {python-dali-0.11 → python_dali-0.12}/setup.cfg +3 -5
  15. python-dali-0.11/dali/version.py +0 -1
  16. {python-dali-0.11 → python_dali-0.12}/COPYING +0 -0
  17. {python-dali-0.11 → python_dali-0.12}/COPYING.LESSER +0 -0
  18. {python-dali-0.11 → python_dali-0.12}/LICENSE +0 -0
  19. {python-dali-0.11 → python_dali-0.12}/dali/__init__.py +0 -0
  20. {python-dali-0.11 → python_dali-0.12}/dali/address.py +0 -0
  21. {python-dali-0.11 → python_dali-0.12}/dali/command.py +0 -0
  22. {python-dali-0.11 → python_dali-0.12}/dali/device/__init__.py +0 -0
  23. {python-dali-0.11 → python_dali-0.12}/dali/device/general.py +0 -0
  24. {python-dali-0.11 → python_dali-0.12}/dali/device/helpers.py +0 -0
  25. {python-dali-0.11 → python_dali-0.12}/dali/device/light.py +0 -0
  26. {python-dali-0.11 → python_dali-0.12}/dali/device/occupancy.py +0 -0
  27. {python-dali-0.11 → python_dali-0.12}/dali/device/pushbutton.py +0 -0
  28. {python-dali-0.11 → python_dali-0.12}/dali/device/sequences.py +0 -0
  29. {python-dali-0.11 → python_dali-0.12}/dali/driver/__init__.py +0 -0
  30. {python-dali-0.11 → python_dali-0.12}/dali/driver/atxled.py +0 -0
  31. {python-dali-0.11 → python_dali-0.12}/dali/driver/daliserver.py +0 -0
  32. {python-dali-0.11 → python_dali-0.12}/dali/driver/hasseb.py +0 -0
  33. {python-dali-0.11 → python_dali-0.12}/dali/driver/trace_logging.py +0 -0
  34. {python-dali-0.11 → python_dali-0.12}/dali/exceptions.py +0 -0
  35. {python-dali-0.11 → python_dali-0.12}/dali/frame.py +0 -0
  36. {python-dali-0.11 → python_dali-0.12}/dali/gear/colour.py +0 -0
  37. {python-dali-0.11 → python_dali-0.12}/dali/gear/emergency.py +0 -0
  38. {python-dali-0.11 → python_dali-0.12}/dali/gear/general.py +0 -0
  39. {python-dali-0.11 → python_dali-0.12}/dali/gear/incandescent.py +0 -0
  40. {python-dali-0.11 → python_dali-0.12}/dali/gear/led.py +0 -0
  41. {python-dali-0.11 → python_dali-0.12}/dali/memory/__init__.py +0 -0
  42. {python-dali-0.11 → python_dali-0.12}/dali/memory/diagnostics.py +0 -0
  43. {python-dali-0.11 → python_dali-0.12}/dali/memory/energy.py +0 -0
  44. {python-dali-0.11 → python_dali-0.12}/dali/memory/info.py +0 -0
  45. {python-dali-0.11 → python_dali-0.12}/dali/memory/location.py +0 -0
  46. {python-dali-0.11 → python_dali-0.12}/dali/memory/maintenance.py +0 -0
  47. {python-dali-0.11 → python_dali-0.12}/dali/memory/oem.py +0 -0
  48. {python-dali-0.11 → python_dali-0.12}/dali/sequences.py +0 -0
  49. {python-dali-0.11 → python_dali-0.12}/dali/tests/__init__.py +0 -0
  50. {python-dali-0.11 → python_dali-0.12}/dali/tests/fakes.py +0 -0
  51. {python-dali-0.11 → python_dali-0.12}/dali/tests/fakes_serial.py +0 -0
  52. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_colour.py +0 -0
  53. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_command.py +0 -0
  54. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_device_sequences.py +0 -0
  55. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_dummy.py +0 -0
  56. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_events.py +0 -0
  57. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_exceptions.py +0 -0
  58. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_fakes.py +0 -0
  59. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_frame.py +0 -0
  60. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_memory.py +0 -0
  61. {python-dali-0.11 → python_dali-0.12}/dali/tests/test_sequences.py +0 -0
  62. {python-dali-0.11 → python_dali-0.12}/pyproject.toml +0 -0
  63. {python-dali-0.11 → python_dali-0.12}/python_dali.egg-info/dependency_links.txt +0 -0
  64. {python-dali-0.11 → python_dali-0.12}/python_dali.egg-info/requires.txt +2 -2
  65. {python-dali-0.11 → python_dali-0.12}/python_dali.egg-info/top_level.txt +0 -0
  66. {python-dali-0.11 → python_dali-0.12}/setup.py +0 -0
@@ -1,25 +1,29 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-dali
3
- Version: 0.11
3
+ Version: 0.12
4
4
  Summary: Interface to DALI lighting systems
5
5
  Home-page: https://github.com/sde1000/python-dali
6
6
  Author: Stephen Early
7
7
  Author-email: steve@assorted.org.uk
8
- License: LGPL3+
9
- Platform: UNKNOWN
8
+ License: "LGPL-3.0-or-later"
10
9
  Classifier: Development Status :: 3 - Alpha
11
10
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
- Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
14
11
  Classifier: Programming Language :: Python :: 3
15
- Requires-Python: >=3.7
12
+ Requires-Python: >=3.8
16
13
  Description-Content-Type: text/x-rst
17
- Provides-Extra: driver-serial
18
- Provides-Extra: driver-unipi
19
- Provides-Extra: test
20
14
  License-File: COPYING
21
15
  License-File: COPYING.LESSER
22
16
  License-File: LICENSE
17
+ Provides-Extra: driver-unipi
18
+ Requires-Dist: pyusb; extra == "driver-unipi"
19
+ Requires-Dist: pymodbus; extra == "driver-unipi"
20
+ Provides-Extra: driver-serial
21
+ Requires-Dist: pyserial-asyncio; extra == "driver-serial"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-asyncio; extra == "test"
25
+ Requires-Dist: pyserial-asyncio; extra == "test"
26
+ Dynamic: license-file
23
27
 
24
28
  python-dali — lighting control interface
25
29
  ========================================
@@ -44,7 +48,9 @@ This library has been written with reference to the following documents:
44
48
  - IEC 62386-201:2009 (fluorescent lamps)
45
49
  - IEC 62386-202:2009 (self-contained emergency lighting)
46
50
  - IEC 62386-205:2009 (supply voltage controller for incandescent lamps)
51
+ - IEC 62386-206:2009 (conversion from digital signal into DC voltage)
47
52
  - IEC 62386-207:2009 (LED modules)
53
+ - IEC 62386-209:2012 (colour control)
48
54
  - IEC 62386-301:2017 (particular requirements for push button input devices)
49
55
  - IEC 62386-303:2017 (particular requirements for occupancy sensor input devices)
50
56
 
@@ -64,7 +70,7 @@ Interface Alliance`_:
64
70
  Python versions supported
65
71
  -------------------------
66
72
 
67
- This library currently requires Python version 3.7 or later.
73
+ This library currently requires Python version 3.8 or later.
68
74
 
69
75
  Stability
70
76
  ---------
@@ -160,11 +166,13 @@ Contributors
160
166
 
161
167
  - David Russell
162
168
 
169
+ - Nikolay Korotkiy
170
+
163
171
 
164
172
  Copyright
165
173
  ---------
166
174
 
167
- python-dali is Copyright (C) 2013–2024 Stephen Early <steve@assorted.org.uk>
175
+ python-dali is Copyright (C) 2013–2026 Stephen Early <steve@assorted.org.uk>
168
176
  and other contributors.
169
177
 
170
178
  It is distributed under the terms of the GNU Lesser General Public
@@ -182,5 +190,3 @@ not, see `this link`_.
182
190
 
183
191
  .. _this link: https://www.gnu.org/licenses/
184
192
  .. _Digital Illumination Interface Alliance: https://www.dali-alliance.org/dali/data.html
185
-
186
-
@@ -21,7 +21,9 @@ This library has been written with reference to the following documents:
21
21
  - IEC 62386-201:2009 (fluorescent lamps)
22
22
  - IEC 62386-202:2009 (self-contained emergency lighting)
23
23
  - IEC 62386-205:2009 (supply voltage controller for incandescent lamps)
24
+ - IEC 62386-206:2009 (conversion from digital signal into DC voltage)
24
25
  - IEC 62386-207:2009 (LED modules)
26
+ - IEC 62386-209:2012 (colour control)
25
27
  - IEC 62386-301:2017 (particular requirements for push button input devices)
26
28
  - IEC 62386-303:2017 (particular requirements for occupancy sensor input devices)
27
29
 
@@ -41,7 +43,7 @@ Interface Alliance`_:
41
43
  Python versions supported
42
44
  -------------------------
43
45
 
44
- This library currently requires Python version 3.7 or later.
46
+ This library currently requires Python version 3.8 or later.
45
47
 
46
48
  Stability
47
49
  ---------
@@ -137,11 +139,13 @@ Contributors
137
139
 
138
140
  - David Russell
139
141
 
142
+ - Nikolay Korotkiy
143
+
140
144
 
141
145
  Copyright
142
146
  ---------
143
147
 
144
- python-dali is Copyright (C) 2013–2024 Stephen Early <steve@assorted.org.uk>
148
+ python-dali is Copyright (C) 2013–2026 Stephen Early <steve@assorted.org.uk>
145
149
  and other contributors.
146
150
 
147
151
  It is distributed under the terms of the GNU Lesser General Public
@@ -1,7 +1,3 @@
1
- import threading
2
- import usb
3
-
4
-
5
1
  ###############################################################################
6
2
  # driver contracts
7
3
  ###############################################################################
@@ -124,113 +120,3 @@ class Listener(Backend):
124
120
  """
125
121
  raise NotImplementedError(
126
122
  'Abstract ``Listener`` does not implement ``listen``')
127
-
128
-
129
- ###############################################################################
130
- # USB backends
131
- ###############################################################################
132
-
133
- class USBBackend(Backend):
134
- """Backend implementation for communicating with USB devices.
135
- """
136
-
137
- def __init__(self, vendor, product, bus=None,
138
- port_numbers=None, interface=0):
139
- self._device = None
140
- # lookup devices by vendor and product
141
- devices = [dev for dev in usb.core.find(
142
- find_all=True,
143
- idVendor=vendor,
144
- idProduct=product
145
- )]
146
- # use first device if bus or port_numers not defined
147
- if bus is None or port_numbers is None:
148
- self._device = devices[0]
149
- else:
150
- for dev in devices:
151
- if dev.bus == bus and dev.port_numbers == port_numbers:
152
- self._device = dev
153
- break
154
- # if queried device not found, raise
155
- if not self._device:
156
- raise usb.core.USBError('Device not found')
157
- # detach kernel driver if necessary
158
- if self._device.is_kernel_driver_active(interface) is True:
159
- self._device.detach_kernel_driver(interface)
160
- # set device configuration
161
- self._device.set_configuration()
162
- # claim interface
163
- usb.util.claim_interface(self._device, interface)
164
- # get active configuration
165
- cfg = self._device.get_active_configuration()
166
- intf = cfg[(0, 0)]
167
- # get write end point
168
- def match_ep_out(e):
169
- return usb.util.endpoint_direction(e.bEndpointAddress) == \
170
- usb.util.ENDPOINT_OUT
171
- self._ep_write = usb.util.find_descriptor(
172
- intf, custom_match=match_ep_out)
173
- # get read end point
174
- def match_ep_in(e):
175
- return usb.util.endpoint_direction(e.bEndpointAddress) == \
176
- usb.util.ENDPOINT_IN
177
- self._ep_read = usb.util.find_descriptor(
178
- intf, custom_match=match_ep_in)
179
-
180
- def read(self, timeout=None):
181
- """Read data from USB device.
182
- """
183
- return self._ep_read.read(self._ep_read.wMaxPacketSize, timeout=timeout)
184
-
185
- def write(self, data):
186
- """Write data to USB device.
187
- """
188
- return self._ep_write.write(data)
189
-
190
- def close(self):
191
- """Close connection to USB device.
192
- """
193
- usb.util.dispose_resources(self._device)
194
-
195
-
196
- class USBListener(USBBackend, Listener):
197
- """Listener implementation for communicating with USB devices.
198
- """
199
-
200
- def __init__(self, driver, vendor, product, bus=None,
201
- port_numbers=None, interface=0):
202
- super(USBListener, self).__init__(
203
- vendor,
204
- product,
205
- bus=bus,
206
- port_numbers=port_numbers,
207
- interface=interface
208
- )
209
- self.driver = driver
210
- # flag whether actually disconnecting from device
211
- self._disconnecting = False
212
- # event to stop listening
213
- self._stop_listening = threading.Event()
214
- # create and start listener thread
215
- self._listener = threading.Thread(target=self.listen)
216
- self._listener.start()
217
-
218
- def listen(self):
219
- """Poll data from USB device.
220
- """
221
- while not self._stop_listening.is_set():
222
- try:
223
- self.driver.receive(self.read())
224
- except usb.core.USBError as e:
225
- # read timeout
226
- if e.errno == 110:
227
- continue
228
- if not self._disconnecting:
229
- self.close()
230
-
231
- def close(self):
232
- """Close connection to USB device.
233
- """
234
- self._disconnecting = True
235
- self._stop_listening.set()
236
- super(USBListener, self).close()
@@ -293,6 +293,8 @@ class tridonic(hid):
293
293
  # Commands sent to the interface
294
294
  # cmd, seq, ctrl, mode, frame (4 bytes), dtr, prio, devtype
295
295
  _cmdtmpl = struct.Struct(">4B4s3B53x")
296
+ # length of the "useful part", without the trailing padding
297
+ _cmdtmpl_useful_size = _cmdtmpl.size - 53
296
298
 
297
299
  # _CMD send in byte 0
298
300
  _CMD_INIT = 0x01
@@ -332,6 +334,8 @@ class tridonic(hid):
332
334
  # Responses received from the interface
333
335
  # Decodes to mode, response type, frame, interval, seq
334
336
  _resptmpl = struct.Struct(">BB4sHB55x")
337
+ # length of the "useful part", without the trailing padding
338
+ _resptmpl_useful_size = _resptmpl.size - 55
335
339
  _MODE_INFO = 0x01 # Response to an init command
336
340
  _MODE_OBSERVE = 0x11 # Other traffic observed on the bus
337
341
  _MODE_RESPONSE = 0x12 # Response to a send command
@@ -384,9 +388,13 @@ class tridonic(hid):
384
388
  self._bus_watch_data_available = asyncio.Event()
385
389
  self._bus_watch_data = []
386
390
 
391
+ def _raw_write(self, buf: bytes):
392
+ self._log.debug(f"_raw_write: {_hex(buf[0:self._cmdtmpl_useful_size])}")
393
+ os.write(self._f, buf)
394
+
387
395
  def _initialise_device(self):
388
396
  # Read firmware version; pick up the reply in _handle_read
389
- os.write(self._f, self._cmd(
397
+ self._raw_write(self._cmd(
390
398
  tridonic._CMD_INIT, tridonic._CMD_INIT_READVERSION))
391
399
 
392
400
  async def _power_supply(self, supply_on):
@@ -396,7 +404,7 @@ class tridonic(hid):
396
404
  self._POWER_SUPPLY_ON if supply_on else self._POWER_SUPPLY_OFF)
397
405
 
398
406
  try:
399
- os.write(self._f, data)
407
+ self._raw_write(data)
400
408
  except OSError:
401
409
  # The device has failed. Disconnect, schedule a
402
410
  # reconnection, and report this command as failed.
@@ -421,7 +429,7 @@ class tridonic(hid):
421
429
  await self.connected.wait()
422
430
  async with self._command_semaphore:
423
431
  seq = next(self._cmd_seq)
424
- self._log.debug("Sending with seq %x", seq)
432
+ self._log.debug(f"Sending with {seq=:02x}")
425
433
  event = asyncio.Event()
426
434
  messages = []
427
435
  # If seq is in self._outstanding this means we've wrapped
@@ -435,7 +443,7 @@ class tridonic(hid):
435
443
  mode=self._command_mode(frame),
436
444
  frame=frame.pack_len(4))
437
445
  try:
438
- os.write(self._f, data)
446
+ self._raw_write(data)
439
447
  except OSError:
440
448
  # The device has failed. Disconnect, schedule a
441
449
  # reconnection, and report this command as failed.
@@ -446,11 +454,17 @@ class tridonic(hid):
446
454
  outstanding_transmissions = 2 if command.sendtwice else 1
447
455
  response = None
448
456
  while outstanding_transmissions or response is None:
449
- self._log.debug(f"waiting for {outstanding_transmissions=} "
450
- "{response=}")
457
+ self._log.debug(f"_send_raw waiting for {outstanding_transmissions=} "
458
+ f"{response=!s}")
451
459
  if len(messages) == 0:
452
- await event.wait()
453
- event.clear()
460
+ try:
461
+ await asyncio.wait_for(event.wait(), timeout=0.2)
462
+ except TimeoutError:
463
+ self._log.error(f"_send_raw timed out {seq=:02x}, faking an error response")
464
+ response = dali.frame.BackwardFrameError(255)
465
+ break
466
+ finally:
467
+ event.clear()
454
468
  message = messages.pop(0)
455
469
  if message == "fail":
456
470
  # The device has gone away, possibly in the middle
@@ -461,11 +475,12 @@ class tridonic(hid):
461
475
  # The message mode is guaranteed to be _MODE_RESPONSE
462
476
  mode, rtype, frame, interval, seq = self._resptmpl.unpack(
463
477
  message)
464
- self._log.debug(f"message mode={mode:02x} rtype={rtype:02x} frame={frame} interval={interval:04x} seq={seq:02x}")
478
+ self._log.debug(f"_send_raw got message: {mode=:02x} {rtype=:02x} frame={_hex(frame)} {interval=:04x} {seq=:02x}")
465
479
  if rtype in (self._RESPONSE_FRAME_DALI16,
466
480
  self._RESPONSE_FRAME_DALI24):
467
481
  # XXX check the frame contents?
468
482
  outstanding_transmissions -= 1
483
+ self._log.debug(f"_send_raw rtype DALI16 or DALI24, new {outstanding_transmissions=}")
469
484
  elif rtype == self._RESPONSE_FRAME_DALI8:
470
485
  response = dali.frame.BackwardFrame(frame)
471
486
  elif rtype == self._RESPONSE_INFO \
@@ -474,7 +489,8 @@ class tridonic(hid):
474
489
  elif rtype == self._RESPONSE_NO_FRAME:
475
490
  response = "no"
476
491
  else:
477
- self._log.debug(f"didn't understand {rtype=}")
492
+ self._log.debug(f"didn't understand {rtype=:02x}")
493
+ self._log.debug(f"_send_raw finished {seq=:02x}")
478
494
  del self._outstanding[seq], event, messages
479
495
  if command.response:
480
496
  # Construct response and return it
@@ -498,13 +514,13 @@ class tridonic(hid):
498
514
  # Wait for data
499
515
  if len(self._bus_watch_data) == 0:
500
516
  if current_command:
501
- self._log.debug("Bus watch waiting with timeout")
517
+ self._log.debug("bus_watch waiting with timeout")
502
518
  try:
503
519
  await asyncio.wait_for(self._bus_watch_data_available.wait(), 0.2)
504
520
  except asyncio.TimeoutError:
505
521
  pass
506
522
  else:
507
- self._log.debug("Bus watch waiting for data, no timeout")
523
+ self._log.debug("bus_watch waiting for data, no timeout")
508
524
  await self._bus_watch_data_available.wait()
509
525
  self._bus_watch_data_available.clear()
510
526
 
@@ -515,10 +531,10 @@ class tridonic(hid):
515
531
  else:
516
532
  timeout = False
517
533
  message = self._bus_watch_data.pop(0)
518
- self._log.debug("bus_watch message %s", _hex(message[0:9]))
534
+ self._log.debug("bus_watch message %s", _hex(message[0:self._resptmpl_useful_size]))
519
535
  origin, rtype, raw_frame, interval, seq = self._resptmpl.unpack(message)
520
536
  if origin not in (self._MODE_OBSERVE, self._MODE_RESPONSE):
521
- self._log.debug("bus_watch: unexpected packet mode, ignoring")
537
+ self._log.warning("bus_watch: unexpected packet mode, ignoring")
522
538
  continue
523
539
  if rtype == self._RESPONSE_FRAME_DALI16:
524
540
  frame = dali.frame.ForwardFrame(16, raw_frame)
@@ -545,7 +561,7 @@ class tridonic(hid):
545
561
  # We are waiting for a repeat of the command
546
562
  if timeout:
547
563
  # We didn't get it: report a failed command
548
- self._log.debug("Failed sendtwice command: %s", current_command)
564
+ self._log.warning("Failed sendtwice command: %s", current_command)
549
565
  self.bus_traffic._invoke(current_command, None, True)
550
566
  current_command = None
551
567
  continue
@@ -557,18 +573,23 @@ class tridonic(hid):
557
573
  current_command = None
558
574
  continue
559
575
  else:
560
- self._log.debug("Failed config command (second frame didn't match): %s", current_command)
576
+ self._log.warning("Failed config command (second frame didn't match): %s", current_command)
561
577
  self.bus_traffic._invoke(current_command, None, True)
562
578
  current_command = None
563
579
  # Fall through to continue processing frame
564
580
  elif isinstance(frame, dali.frame.BackwardFrame):
565
581
  # Error: config commands don't get backward frames.
566
- self._log.debug("Failed config command %s with backward frame",
582
+ self._log.error("Failed config command %s with backward frame",
567
583
  current_command)
568
584
  self.bus_traffic._invoke(current_command, None, True)
569
585
  current_command = None
586
+ elif frame == "no":
587
+ self._log.warning("No frame in response to a sendtwice command: %s", current_command)
588
+ self.bus_traffic._invoke(current_command, None, True)
589
+ current_command = None
590
+ continue
570
591
  else:
571
- self._log.debug("Unexpected response waiting for retransmit of config command")
592
+ self._log.error("Unexpected response waiting for retransmit of config command, frame = %s", frame)
572
593
  elif current_command.response:
573
594
  # We are waiting for a response
574
595
  if timeout or frame == "no":
@@ -620,13 +641,13 @@ class tridonic(hid):
620
641
  # self._log.debug("End of loop")
621
642
 
622
643
  def _handle_read(self, data):
623
- self._log.debug("_handle_read %s", _hex(data[0:9]))
644
+ self._log.debug("_handle_read %s", _hex(data[0:self._resptmpl_useful_size]))
624
645
  if data[0] == self._MODE_INFO:
625
646
  # Response to initialisation command
626
647
  if not self.firmware_version:
627
648
  self.firmware_version = f"{data[3]}.{data[4]}"
628
649
  # Now read the serial number
629
- os.write(self._f, self._cmd(
650
+ self._raw_write(self._cmd(
630
651
  tridonic._CMD_INIT, tridonic._CMD_INIT_READSERIAL))
631
652
  elif not self.serial:
632
653
  self.serial = _hex(data[1:5])
@@ -644,17 +665,20 @@ class tridonic(hid):
644
665
  self._bus_watch_data.append(data)
645
666
  self._bus_watch_data_available.set()
646
667
  seq = data[8]
647
- # The Tridonic DALI USB has a firmware bug. When it
648
- # observes a frame on the bus, not generated by itself,
649
- # that matches the most recent frame it transmitted, it
650
- # reports it as if it had transmitted it itself. We
651
- # ignore this - the sequence number will have been removed
652
- # from self._outstanding.
653
668
  if seq in self._outstanding:
669
+ self._log.debug(f"_handle_read: signaling for {seq=:02x}")
654
670
  event, messages = self._outstanding[seq]
655
671
  messages.append(data)
656
672
  event.set()
657
673
  del event, messages
674
+ else:
675
+ # The Tridonic DALI USB has a firmware bug. When it
676
+ # observes a frame on the bus, not generated by itself,
677
+ # that matches the most recent frame it transmitted, it
678
+ # reports it as if it had transmitted it itself. We
679
+ # ignore this - the sequence number will have been removed
680
+ # from self._outstanding.
681
+ self._log.debug(f"_handle_read: nothing to do for {seq=:02x}")
658
682
 
659
683
  else:
660
684
  self._log.debug("Unknown response mode %x", data[0])
@@ -717,7 +741,7 @@ class hasseb(hid):
717
741
  async with self._command_lock:
718
742
  times = 2 if command.sendtwice else 1
719
743
  for rep in range(times):
720
- os.write(self._f, frame.pack_len(2))
744
+ self._raw_write(frame.pack_len(2))
721
745
  # Earlier commands may have left a response available that
722
746
  # we need to ignore. We're only interested in responses
723
747
  # that become available in the future.
@@ -590,13 +590,29 @@ class DriverLubaRs232(DriverSerialBase):
590
590
  async with self._tx_lock:
591
591
  _LOG.debug("Sending LUBA device settings")
592
592
  mode_settings = 0b00000000
593
+ # 7: 1 = activates the DALI ping
594
+ # 6: 1 = Deactivates sending of DALI frames during "Initialize" mode
595
+ # 5: 1 = Deactivates sending of DALI frames during "Quiescent" mode
596
+ # 4..0: Reserved
593
597
  event_settings = 0b00010010
598
+ # 7: 1 = deactivates all events
599
+ # 6: 1 = deactivates events for successfully sending a DALI frame
600
+ # 5: 1 = deactivates events for receiving a DALI frame
601
+ # 4: 1 = deactivate events for the send-buffer (full/empty)
602
+ # 3: 1 = deactivates including the tick in events
603
+ # 2: 1 = deactivates including the line number in events
604
+ # 1: 1 = Deactivates events for macros
605
+ # 0: reserved
606
+ hardware_settings = 0b00000000
607
+ # 7: 1 = turn on bus power supply
608
+ # 6..0: Reserved
594
609
  tx_ints = [
595
610
  0x59, # ASCII 'Y'
596
611
  DriverLubaRs232.LubaCmd.READ_WRITE_SETTINGS_CMD.value, # LUBA Command
597
- 2, # Length
612
+ 3, # Length
598
613
  mode_settings,
599
614
  event_settings,
615
+ hardware_settings,
600
616
  None, # Checksum
601
617
  ]
602
618
  # Fill in the checksum
@@ -910,9 +926,29 @@ class DriverLubaRs232(DriverSerialBase):
910
926
  f"Wrong event type 0x{self._buffer[1]:02x}, expected 0x21"
911
927
  )
912
928
 
913
- # QUERY DEVICE INFO supports two "sets" of data, this driver will
914
- # only ever request set "0", so it is safe enough to assume that
915
- # this is what the response refers to
929
+ # QUERY DEVICE INFO supports two "sets" of data, and the only way
930
+ # differentiate between them is to check for response length.
931
+ # When querying request set "0", response length should be 20 bytes,
932
+ # and for request set "1", response length should be 18 bytes.
933
+ # Currently this driver only ever requests set "0", therefore the
934
+ # harcoded response handling.
935
+ try:
936
+ payload_length = self._buffer[2]
937
+ except IndexError:
938
+ raise ValueError(
939
+ f"Invalid data for QUERY DEVICE INFO response packet"
940
+ )
941
+
942
+ if payload_length not in [20, 18]:
943
+ raise ValueError(
944
+ f"Unexpected payload length {payload_length} for QUERY DEVICE INFO response"
945
+ )
946
+
947
+ if payload_length == 18:
948
+ raise NotImplementedError(
949
+ f"QUERY DEVICE INFO with set \"1\" is not yet implemented"
950
+ )
951
+
916
952
  info = DriverLubaRs232.LubaDeviceInfo(
917
953
  gtin=int.from_bytes(bytes(received_data[3:9]), byteorder="big"),
918
954
  id=int.from_bytes(bytes(received_data[9:17]), byteorder="big"),
@@ -2,8 +2,8 @@ from dali.command import from_frame
2
2
  from dali.driver.base import AsyncDALIDriver
3
3
  from dali.driver.base import DALIDriver
4
4
  from dali.driver.base import SyncDALIDriver
5
- from dali.driver.base import USBBackend
6
- from dali.driver.base import USBListener
5
+ from dali.driver.usb import USBBackend
6
+ from dali.driver.usb import USBListener
7
7
  from dali.frame import BackwardFrame
8
8
  from dali.frame import ForwardFrame
9
9
  import logging
@@ -8,10 +8,15 @@
8
8
  import logging
9
9
  from time import sleep
10
10
 
11
- from pymodbus.client.sync import (
12
- ModbusSerialClient as pySerial,
13
- ModbusTcpClient as pyRtu,
14
- )
11
+ import pymodbus
12
+ from packaging import version
13
+
14
+ PYMODBUS_VERSION = version.parse(pymodbus.__version__)
15
+
16
+ if PYMODBUS_VERSION >= version.parse("3.0.0"):
17
+ from pymodbus.client import ModbusSerialClient, ModbusTcpClient
18
+ else:
19
+ from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient
15
20
 
16
21
  from dali.driver.base import DALIDriver, SyncDALIDriver
17
22
  from dali.frame import BackwardFrame, ForwardFrame
@@ -40,11 +45,16 @@ class RemoteArm:
40
45
 
41
46
  def __init__(self, host, unit=1, baud=19200, timeout=1):
42
47
  if host.startswith("/"):
43
- self.pymc = pySerial(
44
- method="rtu", port=host, baudrate=baud, timeout=timeout
45
- )
48
+ if PYMODBUS_VERSION >= version.parse("3.0.0"):
49
+ self.pymc = ModbusSerialClient(
50
+ port=host, baudrate=baud, timeout=timeout
51
+ )
52
+ else:
53
+ self.pymc = ModbusSerialClient(
54
+ method="rtu", port=host, baudrate=baud, timeout=timeout
55
+ )
46
56
  else:
47
- self.pymc = pyRtu(host=host, port=502)
57
+ self.pymc = ModbusTcpClient(host=host, port=502)
48
58
  self.unit = unit
49
59
 
50
60
  def close(self):
@@ -0,0 +1,114 @@
1
+ import threading
2
+ import usb
3
+
4
+ from dali.driver.base import Backend, Listener
5
+
6
+
7
+ ###############################################################################
8
+ # USB backends
9
+ ###############################################################################
10
+
11
+ class USBBackend(Backend):
12
+ """Backend implementation for communicating with USB devices.
13
+ """
14
+
15
+ def __init__(self, vendor, product, bus=None,
16
+ port_numbers=None, interface=0):
17
+ self._device = None
18
+ # lookup devices by vendor and product
19
+ devices = [dev for dev in usb.core.find(
20
+ find_all=True,
21
+ idVendor=vendor,
22
+ idProduct=product
23
+ )]
24
+ # use first device if bus or port_numers not defined
25
+ if bus is None or port_numbers is None:
26
+ self._device = devices[0]
27
+ else:
28
+ for dev in devices:
29
+ if dev.bus == bus and dev.port_numbers == port_numbers:
30
+ self._device = dev
31
+ break
32
+ # if queried device not found, raise
33
+ if not self._device:
34
+ raise usb.core.USBError('Device not found')
35
+ # detach kernel driver if necessary
36
+ if self._device.is_kernel_driver_active(interface) is True:
37
+ self._device.detach_kernel_driver(interface)
38
+ # set device configuration
39
+ self._device.set_configuration()
40
+ # claim interface
41
+ usb.util.claim_interface(self._device, interface)
42
+ # get active configuration
43
+ cfg = self._device.get_active_configuration()
44
+ intf = cfg[(0, 0)]
45
+ # get write end point
46
+ def match_ep_out(e):
47
+ return usb.util.endpoint_direction(e.bEndpointAddress) == \
48
+ usb.util.ENDPOINT_OUT
49
+ self._ep_write = usb.util.find_descriptor(
50
+ intf, custom_match=match_ep_out)
51
+ # get read end point
52
+ def match_ep_in(e):
53
+ return usb.util.endpoint_direction(e.bEndpointAddress) == \
54
+ usb.util.ENDPOINT_IN
55
+ self._ep_read = usb.util.find_descriptor(
56
+ intf, custom_match=match_ep_in)
57
+
58
+ def read(self, timeout=None):
59
+ """Read data from USB device.
60
+ """
61
+ return self._ep_read.read(self._ep_read.wMaxPacketSize, timeout=timeout)
62
+
63
+ def write(self, data):
64
+ """Write data to USB device.
65
+ """
66
+ return self._ep_write.write(data)
67
+
68
+ def close(self):
69
+ """Close connection to USB device.
70
+ """
71
+ usb.util.dispose_resources(self._device)
72
+
73
+
74
+ class USBListener(USBBackend, Listener):
75
+ """Listener implementation for communicating with USB devices.
76
+ """
77
+
78
+ def __init__(self, driver, vendor, product, bus=None,
79
+ port_numbers=None, interface=0):
80
+ super(USBListener, self).__init__(
81
+ vendor,
82
+ product,
83
+ bus=bus,
84
+ port_numbers=port_numbers,
85
+ interface=interface
86
+ )
87
+ self.driver = driver
88
+ # flag whether actually disconnecting from device
89
+ self._disconnecting = False
90
+ # event to stop listening
91
+ self._stop_listening = threading.Event()
92
+ # create and start listener thread
93
+ self._listener = threading.Thread(target=self.listen)
94
+ self._listener.start()
95
+
96
+ def listen(self):
97
+ """Poll data from USB device.
98
+ """
99
+ while not self._stop_listening.is_set():
100
+ try:
101
+ self.driver.receive(self.read())
102
+ except usb.core.USBError as e:
103
+ # read timeout
104
+ if e.errno == 110:
105
+ continue
106
+ if not self._disconnecting:
107
+ self.close()
108
+
109
+ def close(self):
110
+ """Close connection to USB device.
111
+ """
112
+ self._disconnecting = True
113
+ self._stop_listening.set()
114
+ super(USBListener, self).close()
@@ -7,5 +7,6 @@ extensions and control gear specific features).
7
7
  import dali.gear.general
8
8
  import dali.gear.emergency
9
9
  import dali.gear.incandescent
10
+ import dali.gear.converter
10
11
  import dali.gear.colour
11
12
  import dali.gear.led # noqa: F401
@@ -0,0 +1,173 @@
1
+ """Commands and responses from IEC 62386 part 206.
2
+
3
+ This part covers conversion from digital signal into DC voltage.
4
+ """
5
+
6
+ from dali import command
7
+ from dali.gear.general import _StandardCommand, QueryExtendedVersionNumberMixin
8
+
9
+
10
+ class _ConversionCommand(_StandardCommand):
11
+ devicetype = 5
12
+
13
+
14
+ class _ConversionConfigCommand(_ConversionCommand):
15
+ """An incandescent lighting configuration command as defined in
16
+ section 11.3.4.1 of IEC 62386-206:2009.
17
+ """
18
+ sendtwice = True
19
+
20
+
21
+ ###############################################################################
22
+ # Commands from IEC 62386-206 section 11.3.4.1
23
+ ###############################################################################
24
+
25
+ class SetOutputRange1To10V(_ConversionConfigCommand):
26
+ """Set the output range to 1V - 10V
27
+
28
+ Converters without this feature shall not react.
29
+ """
30
+ _cmdval = 224
31
+
32
+
33
+ class SetOutputRange0To10V(_ConversionConfigCommand):
34
+ """Set the output range to 0V - 10V
35
+
36
+ Converters without this feature shall not react.
37
+ """
38
+ _cmdval = 225
39
+
40
+
41
+ class SwitchOnInternalPullUp(_ConversionConfigCommand):
42
+ """Switch on the internal pull-up resistor at the control voltage output
43
+
44
+ Converters without this feature shall not react.
45
+ """
46
+ _cmdval = 226
47
+
48
+
49
+ class SwitchOffInternalPullUp(_ConversionConfigCommand):
50
+ """Switch off the internal pull-up resistor at the control voltage output
51
+
52
+ Converters without this feature shall not react.
53
+ """
54
+ _cmdval = 227
55
+
56
+
57
+ class StoreDtrAsPhysicalMinimum(_ConversionConfigCommand):
58
+ """The physical minimum level shall be changed to the value given in the DTR"""
59
+ _cmdval = 228
60
+
61
+
62
+ class SelectDimmingCurve(_ConversionConfigCommand):
63
+ """Select Dimming Curve
64
+
65
+ If DTR0 is 0 then selects the standard logarithmic curve
66
+
67
+ If DTR0 is 1 then selects a linear dimming curve
68
+
69
+ Other values of DTR0 are reserved and will not change the dimming
70
+ curve. The setting is stored in non-volatile memory and is not
71
+ cleared by the Reset command.
72
+ """
73
+ _cmdval = 229
74
+
75
+
76
+ class ResetConverterSettings(_ConversionConfigCommand):
77
+ """Reset parameters which are not affected by RESET
78
+
79
+ All converter settings not influenced by the RESET command shall be set to the
80
+ default values given in clause 10 of IEC 62386-206:2009.
81
+ """
82
+ _cmdval = 230
83
+
84
+
85
+ ###############################################################################
86
+ # Commands from IEC 62386-205 section 11.3.4.2
87
+ ###############################################################################
88
+
89
+ class QueryDimmingCurve(_ConversionCommand):
90
+ """Query Dimming Curve
91
+
92
+ 0 = standard logarithmic
93
+ 1 = linear
94
+ 2-255 = reserved for future use
95
+ """
96
+ _cmdval = 238
97
+ response = command.Response
98
+
99
+
100
+ class OutputLevelResponse(command.NumericResponse):
101
+ def __str__(self):
102
+ if isinstance(self.value, int):
103
+ if self.value == 254:
104
+ return "10.16V or more"
105
+ elif self.value == 255:
106
+ return "unknown"
107
+ else:
108
+ return f"{self.value * 0.04} V"
109
+ return self.value
110
+
111
+
112
+ class QueryOutputLevel(_ConversionCommand):
113
+ """Query the output level
114
+
115
+ The answer shall be the analog output level in units of 0.04V, fiving a range of 0V to 10.16V.
116
+
117
+ Raw value 254 maps to 10.16V or higher.
118
+ Raw value of 255 means "the output level is not known".
119
+
120
+ Converters without this feature shall not react.
121
+ """
122
+ _cmdval = 239
123
+ response = OutputLevelResponse
124
+
125
+
126
+ class QueryConverterFeaturesResponse(command.BitmapResponse):
127
+ bits = [
128
+ "0V - 10V output selectable",
129
+ "internal pull-up selectable",
130
+ "detection of output fault selectable",
131
+ "mains relay",
132
+ "output level can be queried",
133
+ "non-logarithmic dimming curve supported",
134
+ "physical selection / lamp fail detection by loss out output supported",
135
+ "physical selection switch supported",
136
+ ]
137
+
138
+
139
+ class QueryConverterFeatures(_ConversionCommand):
140
+ """Query hardware features of the converter"""
141
+ _cmdval = 240
142
+ response = QueryConverterFeaturesResponse
143
+
144
+
145
+ class QueryFailureStatusResponse(command.BitmapResponse):
146
+ bits = [
147
+ "output fault detected",
148
+ ]
149
+
150
+
151
+ class QueryFailureStatus(_ConversionCommand):
152
+ """Query failure status register"""
153
+ _cmdval = 241
154
+ response = QueryFailureStatusResponse
155
+
156
+
157
+ class QueryConverterStatusResponse(command.BitmapResponse):
158
+ bits = [
159
+ "0-10V operation",
160
+ "internal pull-up on",
161
+ "non-logarithmic dimming curve active",
162
+ ]
163
+
164
+
165
+ class QueryConverterStatus(_ConversionCommand):
166
+ """Query the current status of the converter"""
167
+ _cmdval = 242
168
+ response = QueryConverterStatusResponse
169
+
170
+
171
+ class QueryExtendedVersionNumber(QueryExtendedVersionNumberMixin,
172
+ _ConversionCommand):
173
+ pass
@@ -69,6 +69,7 @@ def QueryDT8ColourValue(
69
69
  "'query' must be a value from QueryColourValueDTR enumerator"
70
70
  )
71
71
 
72
+ # 62386-209, command 250, Note 2: start by sending "QUERY ACTUAL LEVEL"
72
73
  yield QueryActualLevel(address)
73
74
  yield DTR0(query.value)
74
75
  msb = yield QueryColourValue(address)
@@ -1,25 +1,29 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-dali
3
- Version: 0.11
3
+ Version: 0.12
4
4
  Summary: Interface to DALI lighting systems
5
5
  Home-page: https://github.com/sde1000/python-dali
6
6
  Author: Stephen Early
7
7
  Author-email: steve@assorted.org.uk
8
- License: LGPL3+
9
- Platform: UNKNOWN
8
+ License: "LGPL-3.0-or-later"
10
9
  Classifier: Development Status :: 3 - Alpha
11
10
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
- Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
14
11
  Classifier: Programming Language :: Python :: 3
15
- Requires-Python: >=3.7
12
+ Requires-Python: >=3.8
16
13
  Description-Content-Type: text/x-rst
17
- Provides-Extra: driver-serial
18
- Provides-Extra: driver-unipi
19
- Provides-Extra: test
20
14
  License-File: COPYING
21
15
  License-File: COPYING.LESSER
22
16
  License-File: LICENSE
17
+ Provides-Extra: driver-unipi
18
+ Requires-Dist: pyusb; extra == "driver-unipi"
19
+ Requires-Dist: pymodbus; extra == "driver-unipi"
20
+ Provides-Extra: driver-serial
21
+ Requires-Dist: pyserial-asyncio; extra == "driver-serial"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-asyncio; extra == "test"
25
+ Requires-Dist: pyserial-asyncio; extra == "test"
26
+ Dynamic: license-file
23
27
 
24
28
  python-dali — lighting control interface
25
29
  ========================================
@@ -44,7 +48,9 @@ This library has been written with reference to the following documents:
44
48
  - IEC 62386-201:2009 (fluorescent lamps)
45
49
  - IEC 62386-202:2009 (self-contained emergency lighting)
46
50
  - IEC 62386-205:2009 (supply voltage controller for incandescent lamps)
51
+ - IEC 62386-206:2009 (conversion from digital signal into DC voltage)
47
52
  - IEC 62386-207:2009 (LED modules)
53
+ - IEC 62386-209:2012 (colour control)
48
54
  - IEC 62386-301:2017 (particular requirements for push button input devices)
49
55
  - IEC 62386-303:2017 (particular requirements for occupancy sensor input devices)
50
56
 
@@ -64,7 +70,7 @@ Interface Alliance`_:
64
70
  Python versions supported
65
71
  -------------------------
66
72
 
67
- This library currently requires Python version 3.7 or later.
73
+ This library currently requires Python version 3.8 or later.
68
74
 
69
75
  Stability
70
76
  ---------
@@ -160,11 +166,13 @@ Contributors
160
166
 
161
167
  - David Russell
162
168
 
169
+ - Nikolay Korotkiy
170
+
163
171
 
164
172
  Copyright
165
173
  ---------
166
174
 
167
- python-dali is Copyright (C) 2013–2024 Stephen Early <steve@assorted.org.uk>
175
+ python-dali is Copyright (C) 2013–2026 Stephen Early <steve@assorted.org.uk>
168
176
  and other contributors.
169
177
 
170
178
  It is distributed under the terms of the GNU Lesser General Public
@@ -182,5 +190,3 @@ not, see `this link`_.
182
190
 
183
191
  .. _this link: https://www.gnu.org/licenses/
184
192
  .. _Digital Illumination Interface Alliance: https://www.dali-alliance.org/dali/data.html
185
-
186
-
@@ -11,7 +11,6 @@ dali/command.py
11
11
  dali/exceptions.py
12
12
  dali/frame.py
13
13
  dali/sequences.py
14
- dali/version.py
15
14
  dali/device/__init__.py
16
15
  dali/device/general.py
17
16
  dali/device/helpers.py
@@ -29,8 +28,10 @@ dali/driver/serial.py
29
28
  dali/driver/trace_logging.py
30
29
  dali/driver/tridonic.py
31
30
  dali/driver/unipi.py
31
+ dali/driver/usb.py
32
32
  dali/gear/__init__.py
33
33
  dali/gear/colour.py
34
+ dali/gear/converter.py
34
35
  dali/gear/emergency.py
35
36
  dali/gear/general.py
36
37
  dali/gear/incandescent.py
@@ -1,13 +1,13 @@
1
1
  [metadata]
2
2
  name = python-dali
3
- version = 0.11
3
+ version = 0.12
4
4
  description = Interface to DALI lighting systems
5
5
  long_description = file: README.rst
6
6
  long_description_content_type = text/x-rst
7
7
  author = Stephen Early
8
8
  author_email = steve@assorted.org.uk
9
9
  url = https://github.com/sde1000/python-dali
10
- license = LGPL3+
10
+ license = "LGPL-3.0-or-later"
11
11
  license_files =
12
12
  COPYING
13
13
  COPYING.LESSER
@@ -15,12 +15,10 @@ license_files =
15
15
  classifiers =
16
16
  Development Status :: 3 - Alpha
17
17
  Intended Audience :: Developers
18
- License :: OSI Approved :: GNU General Public License v3 (GPLv3)
19
- License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)
20
18
  Programming Language :: Python :: 3
21
19
 
22
20
  [options]
23
- python_requires = >=3.7
21
+ python_requires = >=3.8
24
22
  packages =
25
23
  dali
26
24
  dali.device
@@ -1 +0,0 @@
1
- version="0.11 (Fri, 03 May 2024 12:49:40 +0100)"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -3,10 +3,10 @@
3
3
  pyserial-asyncio
4
4
 
5
5
  [driver-unipi]
6
- pymodbus
7
6
  pyusb
7
+ pymodbus
8
8
 
9
9
  [test]
10
- pyserial-asyncio
11
10
  pytest
12
11
  pytest-asyncio
12
+ pyserial-asyncio
File without changes