From 25c96b3d4fa9a542baab266ceea56871a630aab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Thu, 2 Mar 2023 00:30:02 +0100 Subject: [PATCH 01/15] hid: debugging: don't silently discard exception info I got stuck for a little while because my `/dev/hidrawX` had wrong permissions. Even uncommenting that debug logging setup in the demos didn't help. Let's at least log these exceptions -- even if still only with a debug severity. --- dali/driver/hid.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dali/driver/hid.py b/dali/driver/hid.py index fc84c06..9f618a0 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -123,18 +123,20 @@ def connect(self): path = glob.glob(self._path) else: path = [self._path] + ex = None if path: try: if self._glob: self._log.debug("trying concrete path %s", path[0]) self._f = os.open(path[0], os.O_RDWR | os.O_NONBLOCK) - except: + except Exception as e: self._f = None + ex = e else: self._log.debug("path %s not found", self._path) if not self._f: # It didn't work. Schedule a reconnection attempt if we can. - self._log.debug("hid failed to open %s - waiting to try again", self._path) + self._log.debug("hid failed to open %s (%s) - waiting to try again", self._path, ex) self._reconnect_task = asyncio.create_task(self._reconnect()) return False self._reconnect_count = 0 From 8c14368234b668a0ab1f6b29fee829f273bc8ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Mon, 6 Mar 2023 16:53:07 +0100 Subject: [PATCH 02/15] Parse events from movement and light sensors by default Without importing these modules explicitly, the `examples/async-buswatch.py` would just report back stuff like `UnknownEvent(short_address=43, data=11)`. Rather than letting all consumers import everything, let's follow the pattern set forth by the `pushbutton.py`, and import other decoders as well. Fixes: e79e39d Add support for DALI Occupancy Sensor devices Fixes: f0a0755 Add support for DALI Light Sensor devices --- dali/device/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dali/device/__init__.py b/dali/device/__init__.py index aa52848..cef9901 100644 --- a/dali/device/__init__.py +++ b/dali/device/__init__.py @@ -7,3 +7,5 @@ import dali.device.general import dali.device.sequences import dali.device.pushbutton # noqa: F401 +import dali.device.occupancy # noqa: F401 +import dali.device.light # noqa: F401 From 735ecda06db42d84b33f303190175acb1e1064d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 03:17:10 +0200 Subject: [PATCH 03/15] led: DT8: recognize Colour Control device type when parsing a BackwardFrame Without this patch, dumping DALI traffic would show stuff like this: QueryDeviceType(
) -> multiple QueryNextDeviceType(
) -> LED lamp QueryNextDeviceType(
) -> BackwardFrame(8) QueryNextDeviceType(
) -> none / end --- dali/gear/general.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dali/gear/general.py b/dali/gear/general.py index 4e140d2..1f7333d 100644 --- a/dali/gear/general.py +++ b/dali/gear/general.py @@ -680,6 +680,7 @@ class QueryDeviceTypeResponse(command.Response): 4: "incandescent lamp dimmer", 5: "dc-controlled dimmer", 6: "LED lamp", + 8: "Colour control", 254: "none / end", 255: "multiple"} @@ -700,6 +701,7 @@ class QueryDeviceType(_StandardCommand): 4: incandescent lamps 5: DC-controlled dimmers 6: LED lamps + 8: Colour control The device type affects which application extended commands the device will respond to. From e1931313a64d13ee327d01726088b9f3e0400391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 03:20:35 +0200 Subject: [PATCH 04/15] recognize DT7 switching relays when querying device types --- dali/gear/general.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dali/gear/general.py b/dali/gear/general.py index 1f7333d..99a728c 100644 --- a/dali/gear/general.py +++ b/dali/gear/general.py @@ -680,6 +680,7 @@ class QueryDeviceTypeResponse(command.Response): 4: "incandescent lamp dimmer", 5: "dc-controlled dimmer", 6: "LED lamp", + 7: "Switching function", 8: "Colour control", 254: "none / end", 255: "multiple"} @@ -701,6 +702,7 @@ class QueryDeviceType(_StandardCommand): 4: incandescent lamps 5: DC-controlled dimmers 6: LED lamps + 7: Switching function 8: Colour control The device type affects which application extended commands the From 60bea1b01a4dfc99ccc3508ead88bda776f97842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 02:16:04 +0200 Subject: [PATCH 05/15] led: DT8: configuration commands need sending twice This is as per 62386-209:2011, section 11.3.4.2, "Application extended configuration commands". --- dali/gear/colour.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dali/gear/colour.py b/dali/gear/colour.py index f30c102..0e0eff8 100644 --- a/dali/gear/colour.py +++ b/dali/gear/colour.py @@ -172,6 +172,7 @@ class CopyReportToTemporary(_ColourCommand): class StoreTYPrimaryN(_ColourCommand): + sendtwice = True uses_dtr0 = True uses_dtr1 = True uses_dtr2 = True @@ -179,11 +180,13 @@ class StoreTYPrimaryN(_ColourCommand): class StoreXYCoordinatePrimaryN(_ColourCommand): + sendtwice = True uses_dtr2 = True _cmdval = 241 class StoreColourTemperatureTcLimit(_ColourCommand): + sendtwice = True uses_dtr0 = True uses_dtr1 = True uses_dtr2 = True @@ -191,11 +194,13 @@ class StoreColourTemperatureTcLimit(_ColourCommand): class StoreGearFeaturesStatus(_ColourCommand): + sendtwice = True uses_dtr0 = True _cmdval = 243 class AssignColourToLinkedChannel(_ColourCommand): + sendtwice = True uses_dtr0 = True _cmdval = 245 From 297841e0b0f938c6fe347c9f80cab374053014ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 02:19:47 +0200 Subject: [PATCH 06/15] led: DT8: fix a copy/paste error in SetDT8ColourValueTc Fixes: b706666 Expand DT8 support with tests and sequences --- dali/gear/sequences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 1ca2e42..6f8352b 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -21,7 +21,7 @@ def SetDT8ColourValueTc( tc_mired: int, ) -> Generator[command.Command, Optional[command.Response], None]: """ - A generator sequence set query the Colour Temperature of a DT8 control + A generator sequence to set the Colour Temperature of a DT8 control gear. Note that this sequence assumes that the address being targeted supports DT8 Tc control, it will not check this before sending commands. From 2898b945998a57c61215d66d7b03379b3c413f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 02:21:22 +0200 Subject: [PATCH 07/15] led: DT8: a sequence for setting Tc limits --- dali/gear/colour.py | 11 +++++++++++ dali/gear/sequences.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/dali/gear/colour.py b/dali/gear/colour.py index 0e0eff8..ffb9d63 100644 --- a/dali/gear/colour.py +++ b/dali/gear/colour.py @@ -185,6 +185,17 @@ class StoreXYCoordinatePrimaryN(_ColourCommand): _cmdval = 241 +class StoreColourTemperatureTcLimitDTR2(IntEnum): + """ + Valid DTR2 values for the StoreColourTemperatureTcLimit command + """ + + TcCoolest = 0 + TcWarmest = 1 + TcPhysicalCoolest = 2 + TcPhysicalWarmest = 3 + + class StoreColourTemperatureTcLimit(_ColourCommand): sendtwice = True uses_dtr0 = True diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 6f8352b..1cb7ea6 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -12,8 +12,9 @@ QueryColourValue, QueryColourValueDTR, SetTemporaryColourTemperature, + StoreColourTemperatureTcLimit, ) -from dali.gear.general import DTR0, DTR1, QueryActualLevel, QueryContentDTR0 +from dali.gear.general import DTR0, DTR1, DTR2, QueryActualLevel, QueryContentDTR0 def SetDT8ColourValueTc( @@ -84,3 +85,29 @@ def QueryDT8ColourValue( col_val = None return col_val + + +def SetDT8TcLimit( + address: GearAddress, + what_limit: int, + tc_mired: int, +) -> Generator[command.Command, Optional[command.Response], None]: + """ + A generator sequence to set the Colour Temperature limit of a DT8 control + gear. Note that this sequence assumes that the address being targeted + supports DT8 Tc control, it will not check this before sending commands. + + :param address: GearAddress (i.e. short, group, broadcast) address to set + :param what_limit: What limit to set, from dali.gear.colour.StoreColourTemperatureTcLimitDTR2 + :param tc_mired: An int of the colour temperature to set, in mired + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + tc_bytes = tc_mired.to_bytes(length=2, byteorder="little") + yield DTR0(tc_bytes[0]) + yield DTR1(tc_bytes[1]) + yield DTR2(what_limit) + yield StoreColourTemperatureTcLimit(address) From b1881c008aca0b269fd09b8834dd9bdc0f1a735b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sat, 1 Apr 2023 00:28:46 +0200 Subject: [PATCH 08/15] led: DT8: a helper for conversion between K and Mirek for Tc The standard talks about "Mirek", which is the SI lingo for Mired. I think that everybody else uses Kelvin, so let's add a helper function. --- dali/gear/colour.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dali/gear/colour.py b/dali/gear/colour.py index ffb9d63..2b3b18a 100644 --- a/dali/gear/colour.py +++ b/dali/gear/colour.py @@ -10,6 +10,21 @@ from dali.gear.general import _StandardCommand +def tc_kelvin_mirek(val: int) -> int: + """ + Convert Correlated Color Temperature (CCT) between Kelvin and Mirek + + When the input is in Kelvin, this function returns the value in Mirek, + and vice versa. + + >>> tc_kelvin_mirek(6300) + 158 + >>> tc_kelvin_mirek(250) + 4000 + """ + return int(1000000 / val) + + class QueryColourValueDTR(IntEnum): """ Enum of all values from Part 209 Table 11 "Query Colour Value". See From 1fb74fedfc606463d4695cabba2df47e2e17215b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Fri, 3 Mar 2023 13:54:03 +0100 Subject: [PATCH 09/15] examples: getting and setting a Tc for DT8 CCT LEDs --- examples/async-tc.py | 128 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100755 examples/async-tc.py diff --git a/examples/async-tc.py b/examples/async-tc.py new file mode 100755 index 0000000..03db80a --- /dev/null +++ b/examples/async-tc.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 + +import asyncio +import logging +import sys + +from dali.address import GearGroup, GearShort +from dali.gear.colour import tc_kelvin_mirek, QueryColourValueDTR, QueryColourStatus, StoreColourTemperatureTcLimitDTR2 +from dali.gear.general import QueryControlGearPresent, QueryActualLevel +from dali.gear.led import QueryDimmingCurve +from dali.gear.sequences import SetDT8ColourValueTc, SetDT8TcLimit, QueryDT8ColourValue +from dali.driver.hid import tridonic +from dali.sequences import QueryDeviceTypes, DALISequenceError + +def print_command_and_response(dev, command, response, config_command_error): + # Note that these will be printed "late" because they are not + # delivered until main() blocks on its next await call + if config_command_error: + print(f"ERROR: failed config command: {command}") + elif command and not response: + print(f"{command}") + else: + print(f"{command} -> {response}") + +def show_usage(): + print(f'Usage: {sys.argv[0]} "show-all-gear" ["detailed"]') + print(f' {sys.argv[0]} ("address" / "group") ("tc" / "physical-cool" / "physical-warm" / "cool" / "warm") ') + sys.exit(1) + +async def scan_control_gear(d, detailed): + for addr in (GearShort(x) for x in range(64)): + try: + device_types = await d.run_sequence(QueryDeviceTypes(addr)) + except DALISequenceError: + continue + + if 6 in device_types: + curve = await d.send(QueryDimmingCurve(addr)) + arc_raw = await d.send(QueryActualLevel(addr)) + if curve.raw_value.as_integer == 0: + if arc_raw.value >= 1: + arc_power = 10 ** (((arc_raw.value-1)/(253/3))-1) + else: + arc_power = 0 + elif curve.raw_value.as_integer == 1: + arc_power = arc_raw.value / 254 + else: + arc_power = None + + if 8 in device_types: + colour_status = await d.send(QueryColourStatus(addr)) + if colour_status.colour_type_colour_temperature_Tc_active: + tc = await d.run_sequence(QueryDT8ColourValue(address=addr, query=QueryColourValueDTR.ColourTemperatureTC)) + if detailed: + tc_coolest = await d.run_sequence(QueryDT8ColourValue(address=addr, query=QueryColourValueDTR.ColourTemperatureTcCoolest)) + tc_physical_coolest = await d.run_sequence(QueryDT8ColourValue(address=addr, query=QueryColourValueDTR.ColourTemperatureTcPhysicalCoolest)) + tc_warmest = await d.run_sequence(QueryDT8ColourValue(address=addr, query=QueryColourValueDTR.ColourTemperatureTcWarmest)) + tc_physical_warmest = await d.run_sequence(QueryDT8ColourValue(address=addr, query=QueryColourValueDTR.ColourTemperatureTcPhysicalWarmest)) + tc_detailed_info = f" ({tc_kelvin_mirek(tc_warmest)}-{tc_kelvin_mirek(tc_coolest)}K, physical {tc_kelvin_mirek(tc_physical_warmest)}-{tc_kelvin_mirek(tc_physical_coolest)}K)" + else: + tc_detailed_info = "" + print(f"{addr}: {arc_power:.01f}%, Tc {tc_kelvin_mirek(tc)}K{tc_detailed_info}") + +async def main(): + d = tridonic("/dev/dali/daliusb-*", glob=True) + + if len(sys.argv) < 2: + show_usage() + + if sys.argv[1] == 'show-all-gear': + mode = 'show-all-gear' + if len(sys.argv) >= 3 and sys.argv[2] == 'detailed': + detailed = True + else: + detailed = False + else: + mode = None + if len(sys.argv) < 4: + show_usage() + + if sys.argv[1] == 'address': + address = GearShort(int(sys.argv[2])) + elif sys.argv[1] == 'group': + address = GearGroup(int(sys.argv[2])) + else: + show_usage() + + if sys.argv[3] == 'physical-cool': + setting_a_limit = StoreColourTemperatureTcLimitDTR2.TcPhysicalCoolest + elif sys.argv[3] == 'physical-warm': + setting_a_limit = StoreColourTemperatureTcLimitDTR2.TcPhysicalWarmest + elif sys.argv[3] == 'cool': + setting_a_limit = StoreColourTemperatureTcLimitDTR2.TcCoolest + elif sys.argv[3] == 'warm': + setting_a_limit = StoreColourTemperatureTcLimitDTR2.TcWarmest + elif sys.argv[3] == 'tc': + setting_a_limit = None + else: + show_usage() + desired_kelvin = int(sys.argv[4]) + tc_mired = tc_kelvin_mirek(desired_kelvin) + + # Uncomment to show a dump of bus traffic + # d.bus_traffic.register(print_command_and_response) + + # If there's a problem sending a command, keep trying + d.exceptions_on_send = False + + d.connect() + await d.connected.wait() + + if mode == 'show-all-gear': + await scan_control_gear(d, detailed) + else: + if setting_a_limit is not None: + command = SetDT8TcLimit(address=address, what_limit=setting_a_limit, tc_mired=tc_mired) + else: + command = SetDT8ColourValueTc(address=address, tc_mired=tc_mired) + await d.run_sequence(command) + + # If we don't sleep here for a moment, the bus_watch task gets + # killed before it delivers our most recent command. + await asyncio.sleep(0.1) + d.disconnect() + +if __name__ == "__main__": + # logging.basicConfig(level=logging.DEBUG) + asyncio.run(main()) From 4548d26cde0e47cca5c7e48eb49a0161241a1a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Wed, 1 Mar 2023 23:40:44 +0100 Subject: [PATCH 10/15] led: better printing of Part 207's Fast Fade Time --- dali/gear/led.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/dali/gear/led.py b/dali/gear/led.py index 20953c8..f75982c 100644 --- a/dali/gear/led.py +++ b/dali/gear/led.py @@ -207,13 +207,30 @@ class QueryOperatingMode(_LEDCommand): _cmdval = 0xfc +class FastFadeTimeResponse(command.NumericResponse): + """ + Response to "Min Fast Fade Time" and "Fast Fade Time". + + Refer to Part 207 section 9.13 and Table 1 "Fast fade time". + """ + + def __str__(self): + if isinstance(self.value, int): + if self.value == 0: + return "shortest" + if self.value > 27: + return f"out of range ({self.value})" + return f"{self.value * 25} ms" + return self.value + + class QueryFastFadeTime(_LEDCommand): - response = command.Response + response = FastFadeTimeResponse _cmdval = 0xfd class QueryMinFastFadeTime(_LEDCommand): - response = command.Response + response = FastFadeTimeResponse _cmdval = 0xfe From 6d45aadb49319a7bac028daed6dc1eadeb48ea18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sun, 23 Apr 2023 23:51:49 +0200 Subject: [PATCH 11/15] tridonic: Fix bus traffic dumping I have more than one control device, which is why the `examples/async-dalitest.py` hits a framing error when checking if there are any control devices connected. When the bus_watch feature is enabled, this hit a Python error due to some incomplete rename/refactoring where this single occurrence was not changed by mistake. Fixes: c72374c ("Update the async Tridonic DALI-USB driver to work with 24-bit frames") --- dali/driver/hid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/driver/hid.py b/dali/driver/hid.py index 9f618a0..8b62c00 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -520,7 +520,7 @@ async def _bus_watch(self): frame = dali.frame.BackwardFrame(raw_frame) elif rtype == self._RESPONSE_NO_FRAME: frame = "no" - elif rtype == self._RESPONSE_BUS_STATUS \ + elif rtype == self._RESPONSE_INFO \ and message[5] == self._BUS_STATUS_FRAMING_ERROR: frame = dali.frame.BackwardFrameError(255) else: From 6923d79cbb1c98f3503da006bff278e1cd673e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Sun, 30 Apr 2023 20:21:05 +0200 Subject: [PATCH 12/15] hid: fix wrong variable name That's the endless joy of languages with no compile-time variable lookup. Fixes: ea01ab3 ("asyncio-based drivers for USB devices that present as HID") --- dali/driver/hid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/driver/hid.py b/dali/driver/hid.py index 8b62c00..37abb3a 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -549,7 +549,7 @@ async def _bus_watch(self): current_command = None continue else: - self._log.debug("Failed config command (second frame didn't match): %s", current_comment) + self._log.debug("Failed config command (second frame didn't match): %s", current_command) self.bus_traffic._invoke(current_command, None, True) current_command = None # Fall through to continue processing frame From 107c3125b98230169ce199e066e252d4d58435fb Mon Sep 17 00:00:00 2001 From: Stephen Early Date: Wed, 19 Jul 2023 08:00:23 +0100 Subject: [PATCH 13/15] Remove obsolete examples --- examples/dalitest.py | 39 --------------------------------------- examples/set_group.py | 13 ------------- examples/set_scene.py | 12 ------------ examples/set_single.py | 14 -------------- examples/tcp_listen.py | 35 ----------------------------------- 5 files changed, 113 deletions(-) delete mode 100755 examples/dalitest.py delete mode 100755 examples/set_group.py delete mode 100755 examples/set_scene.py delete mode 100755 examples/set_single.py delete mode 100755 examples/tcp_listen.py diff --git a/examples/dalitest.py b/examples/dalitest.py deleted file mode 100755 index 73ee337..0000000 --- a/examples/dalitest.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -from dali.address import Short -from dali.gear.general import EnableDeviceType -from dali.gear.general import QueryDeviceType -from dali.gear.emergency import QueryEmergencyFailureStatus -from dali.gear.emergency import QueryEmergencyFeatures -from dali.gear.emergency import QueryEmergencyMode -from dali.gear.emergency import QueryEmergencyStatus -from dali.interface import DaliServer -import logging - -if __name__ == "__main__": - log_format = '%(levelname)s: %(message)s' - logging.basicConfig(format=log_format, level=logging.DEBUG) - - with DaliServer() as d: - for addr in range(0, 64): - cmd = QueryDeviceType(Short(addr)) - r = d.send(cmd) - - logging.info("[%d]: resp: %s" % (addr, r)) - - if r.value == 1: - d.send(EnableDeviceType(1)) - r = d.send(QueryEmergencyMode(Short(addr))) - logging.info(" -- {0}".format(r)) - - d.send(EnableDeviceType(1)) - r = d.send(QueryEmergencyFeatures(Short(addr))) - logging.info(" -- {0}".format(r)) - - d.send(EnableDeviceType(1)) - r = d.send(QueryEmergencyFailureStatus(Short(addr))) - logging.info(" -- {0}".format(r)) - - d.send(EnableDeviceType(1)) - r = d.send(QueryEmergencyStatus(Short(addr))) - logging.info(" -- {0}".format(r)) diff --git a/examples/set_group.py b/examples/set_group.py deleted file mode 100755 index cd803f7..0000000 --- a/examples/set_group.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 - -from dali.address import Group -from dali.gear.general import DAPC -from dali.interface import DaliServer -import sys - -if __name__ == "__main__": - group = int(sys.argv[1]) - level = int(sys.argv[2]) - d = DaliServer("localhost", 55825) - cmd = DAPC(Group(group), level) - d.send(cmd) diff --git a/examples/set_scene.py b/examples/set_scene.py deleted file mode 100755 index 048ad22..0000000 --- a/examples/set_scene.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 - -from dali.address import Broadcast -from dali.gear.general import GoToScene -from dali.interface import DaliServer -import sys - -if __name__ == "__main__": - scene = int(sys.argv[1]) - d = DaliServer("localhost", 55825) - cmd = GoToScene(Broadcast(), scene) - d.send(cmd) diff --git a/examples/set_single.py b/examples/set_single.py deleted file mode 100755 index 3bfcfc5..0000000 --- a/examples/set_single.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 - -from dali.address import Broadcast -from dali.address import Short -from dali.gear.general import DAPC -from dali.interface import DaliServer -import sys - -if __name__ == "__main__": - addr = Short(int(sys.argv[1])) if sys.argv[1] != "all" else Broadcast() - level = int(sys.argv[2]) - d = DaliServer("localhost", 55825) - cmd = DAPC(addr, level) - d.send(cmd) diff --git a/examples/tcp_listen.py b/examples/tcp_listen.py deleted file mode 100755 index ccd1390..0000000 --- a/examples/tcp_listen.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 - -import socket - -__author__ = 'psari' - -TCP_IP = '127.0.0.1' -TCP_PORT = 55825 -BUFFER_SIZE = 20 # Normally 1024, but we want fast response - -while True: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.bind((TCP_IP, TCP_PORT)) - s.listen(1) - - conn, addr = s.accept() - - try: - print("Connection address:", addr) - while 1: - conn.setblocking(0) - conn.settimeout(20.0) - data = conn.recv(BUFFER_SIZE) - if not data: - break - - stream = ":".join("{:02x}".format(ord(chr(c))) for c in data) - print("received data: [{1}] {0}".format(stream, len(data))) - - # conn.send(data) # echo - conn.send(b"\x02\xff\x00\x00") - except: - pass - - conn.close() From 8276c9cf82d62ac801e9f707d9267b36fc24205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Thu, 10 Aug 2023 19:52:05 +0200 Subject: [PATCH 14/15] tridonic: support sending 8-bit backward frames Bug: https://github.com/sde1000/python-dali/discussions/126 --- dali/driver/hid.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dali/driver/hid.py b/dali/driver/hid.py index 37abb3a..4523012 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -404,6 +404,15 @@ async def _power_supply(self, supply_on): self.disconnect(reconnect=True) raise CommunicationError + @staticmethod + def _command_mode(frame): + if len(frame) == 8: + return tridonic._SEND_MODE_DALI8 + if len(frame) == 16: + return tridonic._SEND_MODE_DALI16 + if len(frame) == 24: + return tridonic._SEND_MODE_DALI24 + raise UnsupportedFrameTypeError async def _send_raw(self, command): frame = command.frame @@ -423,8 +432,7 @@ async def _send_raw(self, command): data = self._cmd( self._CMD_SEND, seq, ctrl=self._SEND_CTRL_SENDTWICE if command.sendtwice else 0, - mode=self._SEND_MODE_DALI16 if len(frame) == 16 - else self._SEND_MODE_DALI24, + mode=self._command_mode(frame), frame=frame.pack_len(4)) try: os.write(self._f, data) From 0ebccd7ed46f2a6bb581f1f4c9c8937181f43ea2 Mon Sep 17 00:00:00 2001 From: Olivier Pieters Date: Sun, 12 Nov 2023 12:06:10 +0100 Subject: [PATCH 15/15] Add driver for Lunatune SCI RS232. This is a serial DALI interface, and follows the already existing implementation for the Lunatone LUBA serial driver. --- dali/driver/serial.py | 599 ++++++++++++++++++++++++++++++++++++++- dali/tests/test_dummy.py | 3 +- 2 files changed, 598 insertions(+), 4 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 12ae267..ac17a02 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -1,6 +1,7 @@ + """ serial.py - Driver for serial-based DALI interfaces, including the -Lunatone RS232 LUBA device +Lunatone RS232 LUBA and Lunatone SCI RS232 devices. This file is part of python-dali. @@ -441,7 +442,7 @@ async def wait_dali_raw_response(self) -> int: """ return await self._queue_rx_raw_dali.get() - def reset_dali_raw_response(self) -> None: + def reset_dali_response(self) -> None: """ Forces the queue of received DALI responses to be cleared, logging any responses that are dropped if the queue is not empty @@ -1039,7 +1040,7 @@ async def send( try: # Make sure the received command buffer is empty, so that an # unexpected response can't accidentally be used - self._protocol.reset_dali_raw_response() + self._protocol.reset_dali_response() await self._protocol.send_dali_command(msg) if msg.is_query: response = command.Response(None) @@ -1073,3 +1074,595 @@ async def send( def new_dali_rx_queue(self) -> DistributorQueue: return DistributorQueue(self._protocol.queue_rx_dali) + + + +class DriverSCIRS232(DriverSerialBase): + uri_scheme = "scirs232" + timeout_rx = 0.03 + timeout_tx_confirm = 0.1 + timeout_connect = 1.0 + + class SCIRS232Code(Enum): + """ + All supported SCI mode codes and status codes. Refer Lunatone's + documentation: + hhttps://www.lunatone.com/wp-content/uploads/2018/03/22176438-HS_DALI_SCI_RS232_EN_D0045.pdf + """ + STATUS_OK = 0x0 + STATUS_DALI_NO = 0x1 + SEND_DALI_8 = 0x2 + SEND_DALI_16 = 0x3 + SEND_EDALI = 0x4 + SEND_DSI = 0x5 + SEND_DALI_17 = 0x6 + ERROR = 0x7 + SEND_DALI2_24 = 0x8 + + class SCIRS232DeviceReply(NamedTuple): + """ + Named tuple for storing a set of information about the SCI RS232 device. + + This information is sent in every frame, but updates are ignored after + initialisation of the software. + """ + + id: int + code: int + + class SCIRS232DeviceSettings(NamedTuple): + """ + Named tuple for storing a set of information about the SCI RS232 device. + + These are sent on a per-frame basis, so the device is state-less with + respect to the DALI receive and transmit parameters. + """ + + monitor_enable: bool + identify : bool + echo:bool + + class SCIRS232Protocol(asyncio.Protocol): + """ + This class is internally used by DriverSCIRS232 to implement a state + machine for decoding the incoming serial bytes into SCI RS232 messages, + which in turn wrap DALI frames. The class also handles encoding DALI + frames into SCI RS232 messages, setting the appropriate flags etc. + """ + + MAX_LEN = 5 + CONTROL_ME_MASK = 0b10000000 + CONTROL_IDENTIFY_MASK = 0b01000000 + CONTROL_ECHO_MASK = 0b00100000 + CONTROL_SEND_TWICE_MASK = 0b00010000 + CONTROL_MODE_MASK = 0b00001111 + + STATUS_ID_MASK = 0b11110000 + STATUS_CODE_MASK = 0b00001111 + + class ReadState(Enum): + """ + Enum of states used in the receiver state machine + """ + + WAIT_STATUS = 1 + WAIT_DATA_HI = 2 + WAIT_DATA_MI = 3 + WAIT_DATA_LO = 4 + WAIT_CHECKSUM = 5 + + class ErrorType(Enum): + CHECKSUM = 1 + DALI_BUS_SHORT_CIRCUIT = 2 + DALI_RX_ERROR = 3 + UNKNOWN_COMMAND = 4 + COLLISION_DETECTED = 5 + + class SCIRS232MsgTxConf(NamedTuple): + """ + Named tuple used to enqueue messages + """ + + message: Optional[command.Command] = None + + def __init__(self) -> None: + super().__init__() + self.transport = None + + self._queue_rx_dali = DistributorQueue() + self._queue_rx_raw_dali = asyncio.Queue() + self._queue_rx_info = asyncio.Queue() + self._prev_rx_enable_dt = 0 + self._prev_tx_enable_dt = 0 + self._tx_lock = asyncio.Lock() + self._rx_state = None + self.rx_idle = asyncio.Event() + self._buffer = None + self._rx_expected_len = None + self._rx_received_len = None + self._connected = asyncio.Event() + self._dev_info: Optional[DriverSCIRS232.SCIRS232DeviceReply] = None + self._dev_inst_map: Optional[DeviceInstanceTypeMapper] = None + self._device_settings = DriverSCIRS232.SCIRS232DeviceSettings( + monitor_enable=True, + identify=False, + echo=True) + + self.reset() + + @property + def rx_state(self) -> ReadState: + return self._rx_state + + @rx_state.setter + def rx_state(self, state: ReadState): + if not isinstance(state, self.ReadState): + raise TypeError( + f"rx_state must be a ReadState enum, not {type(state)}" + ) + + self._rx_state = state + if state == self.ReadState.WAIT_STATUS: + self.rx_idle.set() + else: + self.rx_idle.clear() + + @property + def dev_inst_map(self) -> Optional[DeviceInstanceTypeMapper]: + return self._dev_inst_map + + @dev_inst_map.setter + def dev_inst_map(self, value: DeviceInstanceTypeMapper): + self._dev_inst_map = value + + @property + def queue_rx_dali(self) -> DistributorQueue: + return self._queue_rx_dali + + def reset(self): + """ + Returns the state machine to "WAIT_STATUS" + """ + self.rx_state = self.ReadState.WAIT_STATUS + self._buffer = [None] * self.MAX_LEN + self._rx_expected_len = None + self._rx_received_len = 0 + + async def wait_dali_raw_response(self) -> int: + """ + Async method which waits for a raw (i.e. un-decoded) DALI frame + to be received from the SCI RS232 device. + + :return: A received DALI frame, as an int + """ + return await self._queue_rx_raw_dali.get() + + def reset_dali_response(self) -> None: + """ + Forces the queue of received DALI responses to be cleared, logging + any responses that are dropped if the queue is not empty + """ + + # remove backward frames + qlen = self._queue_rx_raw_dali.qsize() + if qlen: + _LOG.critical( + f"SCI RS232 RX DALI queue not empty! {qlen} items in queue!" + ) + try: + item = self._queue_rx_raw_dali.get_nowait() + _LOG.critical(f"SCI RS232 RX DALI queue discarding: {item}") + except asyncio.QueueEmpty: + pass + + # remove information frames (includes errors and sent confirmations) + qlen = self._queue_rx_info.qsize() + if qlen: + _LOG.critical( + f"SCI RS232 RX info DALI queue not empty! {qlen} items in queue!" + ) + try: + item = self._queue_rx_raw_dali.get_nowait() + _LOG.critical(f"SCI RS232 RX info DALI queue discarding: {item}") + except asyncio.QueueEmpty: + pass + + @staticmethod + def _insert_checksum(in_ints: list[int]) -> None: + in_ints[-1] = reduce(xor, in_ints[0:-1]) + + async def send_dali_command(self, tx: command.Command) -> None: + """ + Sends a variable length DALI command (16 or 24 bits), waiting + until the SCI RS232 device confirms it has sent the message before + returning the frame ID. + + :param tx: A single DALI command to send + """ + # Make sure the serial interface is not in the process of reading + # data before we send + await self.rx_idle.wait() + + dali_ints = tx.frame.as_byte_sequence + if not len(dali_ints) in (1, 2, 3): + raise ValueError( + f"Only works with 8, 16 or 24 bit messages, not {8*len(dali_ints)}" + ) + + control_byte = (self._device_settings.monitor_enable << 7) | (self._device_settings.identify << 6) | (self._device_settings.echo << 5) | (tx.sendtwice << 4) + if len(dali_ints) == 1: + control_byte |= 2 + elif len(dali_ints) == 2: + control_byte |= 3 + elif len(dali_ints) == 3: + control_byte |= 8 + + tx_ints = [ + control_byte, + dali_ints[0], + 0 if len(dali_ints) < 2 else dali_ints[1], + 0 if len(dali_ints) < 3 else dali_ints[2], + None, # Checksum + ] + # Fill in the checksum + self._insert_checksum(tx_ints) + + # Use a mutex to ensure only one message is sent at a time, + # waiting for the SCI RS232 device to confirm before sending another + async with self._tx_lock: + _LOG.debug(f"DALI sending message: {tx}") + _LOG.trace( + f"SCI RS232 frame to send: {[f'0x{data:02x}' for data in tx_ints]}" + ) + self.transport.write(bytearray(tx_ints)) + + confirm = await asyncio.wait_for( + self._queue_rx_info.get(), + timeout=DriverSCIRS232.timeout_tx_confirm, + ) + if isinstance(confirm, DriverSCIRS232.SCIRS232DeviceReply): + _LOG.trace(f"SCI RS232 confirmed data with code {confirm.code}") + else: + _LOG.error(f"Received unexpected confirmation object {confirm}") + return + + async def send_device_info_query(self) -> None: + """ + Query some basic information from the SCI RS232 device + """ + # Use a mutex to ensure only one message is sent at a time + async with self._tx_lock: + _LOG.debug("Querying SCI RS232 device info") + tx_ints = [ + 0b11000010, # enable monitoring and identify + 0, + 0, + 0, + None, # Checksum + ] + # Fill in the checksum + self._insert_checksum(tx_ints) + + # empty queue (just in case) + while not self._queue_rx_info.empty(): + dev_info = self._queue_rx_info.get_nowait() + _LOG.warning(f"SCI RS232 info queue not empty, discarting: {dev_info}") + + _LOG.trace( + f"SCI RS232 frame to send: {[f'0x{data:02x}' for data in tx_ints]}" + ) + self.transport.write(bytearray(tx_ints)) + + # Wait for the SCI RS232 device to respond + dev_info = await asyncio.wait_for( + self._queue_rx_info.get(), + timeout=DriverSCIRS232.timeout_tx_confirm, + ) + # Release transmit mutex + + if not isinstance(dev_info, DriverSCIRS232.SCIRS232DeviceReply): + _LOG.error(f"Expected a SCI RS232, but got: {dev_info}") + return + + self._device_info = dev_info + + def _process_byte(self, rx_int: int) -> None: + if not isinstance(rx_int, int): + raise TypeError( + f"Got an item of type: {type(rx_int)}, expected an integer" + ) + + # Handle each state of the state machine + if self._rx_state == self.ReadState.WAIT_STATUS: + self._buffer[0] = rx_int + self._rx_state = self.ReadState.WAIT_DATA_HI + + return + + elif self._rx_state == self.ReadState.WAIT_DATA_HI: + # In the 'WAIT_DATA_HI' state the next byte will be data high + self._buffer[1] = rx_int + self._rx_state = self.ReadState.WAIT_DATA_MI + return + + elif self._rx_state == self.ReadState.WAIT_DATA_MI: + # In the 'WAIT_DATA_MI' state the next byte will be data mid + self._buffer[2] = rx_int + self._rx_state = self.ReadState.WAIT_DATA_LO + return + + elif self._rx_state == self.ReadState.WAIT_DATA_LO: + # Read bytes, up to the maximum expected length + self._buffer[3] = rx_int + self._rx_state = self.ReadState.WAIT_CHECKSUM + return + + elif self._rx_state == self.ReadState.WAIT_CHECKSUM: + # In the 'WAIT_CHECKSUM' state, the next byte will be the + # checksum + self._buffer[4] = rx_int + + # We now have a full frame + _LOG.trace( + f"Raw data: {[f'0x{data:02x}' for data in self._buffer]}" + ) + + # Validate the checksum: XOR all values, excluding the + # synchronisation and checksum + check = reduce(xor, self._buffer[0:4]) + + if check != rx_int: + _LOG.warning( + f"SCI RS232 checksum failure! Calculated: {check}, " + f"Expected: {rx_int}" + ) + self.reset() + return + else: + _LOG.trace("SCI RS232 checksum passed, full frame received") + try: + status = DriverSCIRS232.SCIRS232Code(self._buffer[0] & DriverSCIRS232.SCIRS232Protocol.STATUS_CODE_MASK) + except ValueError: + _LOG.exception( + f"SCI RS232 unknown status code: 0x{self._buffer[0]:02x}" + ) + self.reset() + return + + #TODO: handle status codes / options here + if status == DriverSCIRS232.SCIRS232Code.ERROR: + self._process_error(tuple(self._buffer[:4])) + elif status == DriverSCIRS232.SCIRS232Code.STATUS_OK: + self._process_system_message(self._buffer[0]) + elif status == DriverSCIRS232.SCIRS232Code.STATUS_DALI_NO: + self._process_system_message(self._buffer[0]) + elif status == DriverSCIRS232.SCIRS232Code.SEND_DALI_8: + self._process_dali_frame((self._buffer[3],)) + elif status == DriverSCIRS232.SCIRS232Code.SEND_DALI_16: + self._process_dali_frame(self._buffer[2:4]) + elif status == DriverSCIRS232.SCIRS232Code.SEND_DALI2_24: + self._process_dali_frame(self._buffer[1:4]) + elif (status == DriverSCIRS232.SCIRS232Code.SEND_EDALI) or \ + (status == DriverSCIRS232.SCIRS232Code.SEND_DSI) or \ + (status == DriverSCIRS232.SCIRS232Code.SEND_DALI_17): + _LOG.error( + f"SCI RS232 eDALI, DSI or 17-bit DALI message received. These are not supported." + f" data: {self._buffer[1:4]}" + ) + else: + _LOG.error( + f"SCI RS232 unexpected message, status {self._buffer[0]:02x}," + f" data: {self._buffer[1:4]}" + ) + + self.reset() + return + else: + raise RuntimeError(f"Invalid state: {self._rx_state}") + + def _process_system_message(self, data : int): + device_id_info = DriverSCIRS232.SCIRS232DeviceReply( + id=(data & 0xf0) >> 4,code=data&0xf) + self._queue_rx_info.put_nowait(device_id_info) + + def _process_error(self, data : tuple): + try: + error_type = DriverSCIRS232.SCIRS232Protocol.ErrorType(data[3]) + except ValueError: + _LOG.exception( + f"SCI RS232 unknown error code: 0x{data[3]:02x}" + ) + self.reset() + return + if error_type == DriverSCIRS232.SCIRS232Protocol.ErrorType.CHECKSUM: + error_str = "checksum" + elif error_type == DriverSCIRS232.SCIRS232Protocol.ErrorType.DALI_BUS_SHORT_CIRCUIT: + error_str = "short circuit on the DALI bus" + elif error_type == DriverSCIRS232.SCIRS232Protocol.ErrorType.DALI_RX_ERROR: + error_str = "DALI receive error" + elif error_type == DriverSCIRS232.SCIRS232Protocol.ErrorType.UNKNOWN_COMMAND: + error_str = "unknown command" + else: + error_str = "unknown" + _LOG.error( + f"No string defined for SCI RS232 error code: 0x{data[3]:02x}" + ) + _LOG.error(f"SCI RS232 reports {error_str} error ({error_type})") + self._process_system_message(data[0]) + + def _process_dali_frame(self, received_data: tuple): + """ + Handle a DALI 'event' message, typically these are received when + a DALI frame was observed on the bus by the SCI RS232 device + """ + + _LOG.trace( + f"SCI RS232 DALI frame received: {[f'0x{data:02x}' for data in received_data]}" + ) + + if len(received_data) == 1: + # An 8-bit frame is a response, don't try to decipher it + # here because it depends on context which the 'send()' + # routine will have to handle + self._queue_rx_raw_dali.put_nowait(received_data[0]) + _LOG.trace( + f"Adding raw DALI response to queue: '{received_data[0]}'" + ) + else: + # A 16 or 24-bit frame is an intercepted DALI command, + # it can be deciphered into a Command object + dali_frame = frame.Frame( + bits=8 * len(received_data), data=received_data + ) + try: + dali_command = command.Command.from_frame( + dali_frame, + devicetype=self._prev_rx_enable_dt, + dev_inst_map=self._dev_inst_map, + ) + except TypeError: + _LOG.error( + f"Failed to decode DALI command! Frame: {dali_frame}" + ) + return + if isinstance( + dali_command, dali.gear.general.EnableDeviceType + ): + self._prev_rx_enable_dt = dali_command.param + else: + self._prev_rx_enable_dt = 0 + + _LOG.debug(f"Adding DALI command to queue: {dali_command}") + self._queue_rx_dali.distribute(dali_command) + + def connection_made(self, transport): + self.transport = transport + _LOG.info(f"Serial port opened: {transport}") + self._connected.set() + + def data_received(self, data): + _LOG.trace(f"Serial data received: {data}") + for rx in data: + self._process_byte(rx) + + def connection_lost(self, exc): + _LOG.info("Serial port closed") + self.transport.loop.stop() + + @property + def connected(self) -> asyncio.Event: + return self._connected + + @property + def device_info(self) -> Optional[DriverSCIRS232.SCIRS232DeviceReply]: + return self._dev_info + + + def __init__( + self, + uri: str | ParseResult, + dev_inst_map: Optional[DeviceInstanceTypeMapper] = None, + ): + super().__init__(uri=uri, dev_inst_map=dev_inst_map) + + self.serial_path = self.uri.path + _LOG.info(f"Initialising SCI RS232 driver for '{self.serial_path}'") + self._transport: Optional[serial_asyncio.SerialTransport] = None + self._protocol: Optional[DriverSCIRS232.SCIRS232Protocol] = None + + async def connect(self, *, scan_dev_inst: bool = False) -> None: + if self.is_connected: + _LOG.warning( + f"'connect()' called but SCI RS232 driver already connected" + ) + return + + _LOG.info( + f"Creating serial connection to {self.serial_path}" + ) + + # TODO: Add failure/retry handling + ( + self._transport, + self._protocol, + ) = await serial_asyncio.create_serial_connection( + loop=asyncio.get_event_loop(), + protocol_factory=DriverSCIRS232.SCIRS232Protocol, + url=self.serial_path, + baudrate=38400, + ) + + try: + await asyncio.wait_for( + self._protocol._connected.wait(), + timeout=DriverSCIRS232.timeout_connect, + ) + except asyncio.exceptions.TimeoutError as exc: + _LOG.critical(f"Timeout waiting for driver to connect: {exc}") + raise + + await self._protocol.send_device_info_query() + self._protocol.dev_inst_map = self.dev_inst_map + + self._connected.set() + + # Scan the bus for control devices, and create a mapping of addresses + # to instance types + if scan_dev_inst: + _LOG.info("Scanning DALI bus for control devices") + await self.run_sequence(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 + ) -> Optional[command.Response]: + # Only send if the driver is connected + if not self.is_connected: + _LOG.critical(f"DALI driver cannot send, not connected: {self}") + raise IOError("DALI driver cannot send, not connected") + + 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() + + return response + + def new_dali_rx_queue(self) -> DistributorQueue: + return DistributorQueue(self._protocol.queue_rx_dali) diff --git a/dali/tests/test_dummy.py b/dali/tests/test_dummy.py index 7aa345a..bd568b7 100644 --- a/dali/tests/test_dummy.py +++ b/dali/tests/test_dummy.py @@ -23,7 +23,7 @@ import py import pytest -from dali.driver.serial import DriverSerialBase, DriverLubaRs232, drivers_map +from dali.driver.serial import DriverSerialBase, DriverLubaRs232, DriverSCIRS232, drivers_map from dali.tests.fakes_serial import DriverSerialDummy from dali import address, gear from dali.sequences import QueryDeviceTypes @@ -81,6 +81,7 @@ def test_drivers_map(): drivers = drivers_map() assert drivers["dummy"] == DriverSerialDummy assert drivers["luba232"] == DriverLubaRs232 + assert drivers["scirs232"] == DriverSCIRS232 def test_dummy_init_good(tmp_path):