diff --git a/dali/device/__init__.py b/dali/device/__init__.py index aa528486..cef9901a 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 diff --git a/dali/driver/hid.py b/dali/driver/hid.py index fc84c060..4523012c 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 @@ -402,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 @@ -421,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) @@ -518,7 +528,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: @@ -547,7 +557,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 diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 12ae2674..ac17a023 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/gear/colour.py b/dali/gear/colour.py index f30c1020..2b3b18a3 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 @@ -172,6 +187,7 @@ class CopyReportToTemporary(_ColourCommand): class StoreTYPrimaryN(_ColourCommand): + sendtwice = True uses_dtr0 = True uses_dtr1 = True uses_dtr2 = True @@ -179,11 +195,24 @@ class StoreTYPrimaryN(_ColourCommand): class StoreXYCoordinatePrimaryN(_ColourCommand): + sendtwice = True uses_dtr2 = True _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 uses_dtr1 = True uses_dtr2 = True @@ -191,11 +220,13 @@ class StoreColourTemperatureTcLimit(_ColourCommand): class StoreGearFeaturesStatus(_ColourCommand): + sendtwice = True uses_dtr0 = True _cmdval = 243 class AssignColourToLinkedChannel(_ColourCommand): + sendtwice = True uses_dtr0 = True _cmdval = 245 diff --git a/dali/gear/general.py b/dali/gear/general.py index 4e140d2f..99a728c3 100644 --- a/dali/gear/general.py +++ b/dali/gear/general.py @@ -680,6 +680,8 @@ 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"} @@ -700,6 +702,8 @@ 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 device will respond to. diff --git a/dali/gear/led.py b/dali/gear/led.py index 20953c8b..f75982c4 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 diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 1ca2e42b..1cb7ea62 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( @@ -21,7 +22,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. @@ -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) diff --git a/dali/tests/test_dummy.py b/dali/tests/test_dummy.py index 7aa345a3..bd568b7a 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): diff --git a/examples/async-tc.py b/examples/async-tc.py new file mode 100755 index 00000000..03db80a7 --- /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()) diff --git a/examples/dalitest.py b/examples/dalitest.py deleted file mode 100755 index 73ee3374..00000000 --- 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 cd803f75..00000000 --- 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 048ad229..00000000 --- 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 3bfcfc51..00000000 --- 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 ccd1390a..00000000 --- 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()